openvibers/openvibe.live Commits
A loading route shows the shape of the page on its way (L52 step 6) (#563)
Changes
@@@ -108,5 +108,125 @@/** Placeholders that are still up after a failed load should just go away. */function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }-- window.OVSkeleton = { cards, block, clear, clearAll, ensure };++ // ── Route placeholders (plan L52 step 6; the owner, 11-01: "placeholder elements while it loads, a fake thumbnail,++ // so it is not jarring") ──++ // While a route's code loads, the web runtime keeps the section's space and hides what is in it++ // (section[data-ov-loading] > * { visibility: hidden }). That left an empty 40vh box. Now the box shows the shape of++ // the page on its way, in the network kit's shapes (kit.skeleton: card, row, tile, chat, line, stat), and only when++ // the wait is long enough to see (120 ms): a route already loaded never flashes one.++ const ROUTE_SHAPE = {++ 'page-home': 'cards', 'page-content': 'cards', 'page-moments': 'cards', 'page-search': 'rows',++ 'page-channel': 'player', 'page-stream': 'player', 'page-dashboard': 'dash', 'page-broadcast': 'panel',++ 'page-chat': 'chat', 'page-updates': 'rows', 'page-recap': 'lines', 'page-rundown': 'rows',++ 'page-documentation': 'lines', 'page-paste-viewer': 'lines', 'page-profile': 'rows',++ };++ const ROUTE_CSS = `++[data-ov-loading] > .ovsk-route { visibility: visible; }++.ovsk-route { position: absolute; inset: 0 0 auto 0; z-index: 1; pointer-events: none; display: grid; gap: 16px;++ padding: 20px max(16px, env(safe-area-inset-left, 0px)); max-width: 1280px; margin: 0 auto; box-sizing: border-box; }++.ovsk-route .ovsk-bar { display: block; }++.ovsk-r-cards { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }++.ovsk-r-card { display: grid; gap: 8px; }++.ovsk-r-card .ovsk-thumb { border-radius: 12px; position: relative; overflow: hidden; }++.ovsk-r-row { display: flex; gap: 12px; align-items: center; }++.ovsk-r-row .ovsk-av { width: 40px; height: 40px; }++.ovsk-r-lines { flex: 1; min-width: 0; display: grid; gap: 7px; }++.ovsk-r-player { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }++@media (min-width: 1000px) { .ovsk-r-player { grid-template-columns: minmax(0, 1fr) 340px; } }++.ovsk-r-video { aspect-ratio: 16 / 9; border-radius: 14px; }++.ovsk-r-chat { display: grid; gap: 12px; align-content: start; }++.ovsk-r-msg { display: flex; gap: 8px; align-items: center; }++.ovsk-r-msg .ovsk-av { width: 22px; height: 22px; }++.ovsk-r-stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }++.ovsk-r-stat { height: 72px; border-radius: 12px; }++.ovsk-r-field { display: grid; gap: 8px; }++.ovsk-r-input { height: 44px; border-radius: 10px; }++.ovsk-route.ovsk-out { opacity: 0; transition: opacity .18s ease; }`;++++ const bar = (h, w, extra = '') => `<span class="ovsk-bar${extra}" style="height:${h}px;width:${w}%"></span>`;++ const WIDTHS = [86, 64, 78, 52, 70, 90, 58];++ function routeShape(kind) {++ const n = (k) => WIDTHS[k % WIDTHS.length];++ if (kind === 'cards') {++ const w = window.innerWidth;++ const count = w < 600 ? 3 : w < 1000 ? 4 : 8;++ let h = '';++ for (let i = 0; i < count; i++) h += `<div class="ovsk-r-card"><span class="ovsk-bar ovsk-thumb"></span>${bar(12, n(i))}${bar(10, n(i + 3) - 20)}</div>`;++ return `${bar(22, 28)}<div class="ovsk-r-cards">${h}</div>`;++ }++ if (kind === 'player') {++ let chat = '';++ for (let i = 0; i < 6; i++) chat += `<div class="ovsk-r-msg"><span class="ovsk-bar ovsk-av"></span>${bar(10, n(i))}</div>`;++ return `<div class="ovsk-r-player"><div style="display:grid;gap:12px"><span class="ovsk-bar ovsk-r-video"></span>${bar(18, 52)}${bar(11, 34)}</div><div class="ovsk-r-chat">${chat}</div></div>`;++ }++ if (kind === 'chat') {++ let chat = '';++ for (let i = 0; i < 10; i++) chat += `<div class="ovsk-r-msg"><span class="ovsk-bar ovsk-av"></span>${bar(10, n(i))}</div>`;++ return `${bar(20, 30)}<div class="ovsk-r-chat">${chat}</div>`;++ }++ if (kind === 'dash') {++ let stats = '';++ for (let i = 0; i < 4; i++) stats += '<span class="ovsk-bar ovsk-r-stat"></span>';++ return `${bar(22, 30)}<div class="ovsk-r-stats">${stats}</div>${routeShape('rows').replace(/^.*?(<div class="ovsk-r-row")/, '$1')}`;++ }++ if (kind === 'panel') {++ let f = '';++ for (let i = 0; i < 5; i++) f += `<div class="ovsk-r-field">${bar(11, 22 + (i % 3) * 8)}<span class="ovsk-bar ovsk-r-input"></span></div>`;++ return `${bar(22, 36)}${f}`;++ }++ if (kind === 'lines') {++ let l = '';++ for (let i = 0; i < 9; i++) l += bar(11, n(i));++ return `${bar(24, 44)}${l}`;++ }++ let rows = '';++ for (let i = 0; i < 7; i++) rows += `<div class="ovsk-r-row"><span class="ovsk-bar ovsk-av"></span><span class="ovsk-r-lines">${bar(12, n(i))}${bar(10, n(i + 2) - 24)}</span></div>`;++ return `${bar(22, 30)}${rows}`;++ }++++ function routeOn(section) {++ if (section.querySelector(':scope > .ovsk-route')) return;++ const kind = ROUTE_SHAPE[section.id] || 'lines';++ ensure();++ if (!document.getElementById('ovsk-route-css')) {++ const st = document.createElement('style'); st.id = 'ovsk-route-css'; st.textContent = ROUTE_CSS;++ document.head.appendChild(st);++ }++ const d = document.createElement('div');++ d.className = `ovsk-route ovsk-route-${kind}`;++ d.setAttribute('role', 'status');++ d.setAttribute('aria-busy', 'true');++ d.setAttribute('aria-label', `Loading ${section.dataset.title || 'the page'}`);++ d.innerHTML = routeShape(kind);++ section.prepend(d);++ }++ function routeOff(section) {++ section.querySelectorAll(':scope > .ovsk-route').forEach((d) => {++ d.classList.add('ovsk-out');++ setTimeout(() => { try { d.remove(); } catch { /* gone */ } }, 200);++ });++ }++++ const pending = new WeakMap();++ function watchRoutes() {++ if (!window.MutationObserver || watchRoutes.on) return;++ watchRoutes.on = true;++ new MutationObserver((list) => {++ for (const m of list) {++ const s = m.target;++ if (!(s instanceof HTMLElement) || !s.classList.contains('page')) continue;++ if (s.hasAttribute('data-ov-loading')) {++ if (pending.has(s)) continue;++ pending.set(s, setTimeout(() => { pending.delete(s); if (s.hasAttribute('data-ov-loading')) routeOn(s); }, 120));++ } else {++ clearTimeout(pending.get(s));++ pending.delete(s);++ routeOff(s);++ }++ }++ }).observe(document.body || document.documentElement, { subtree: true, attributes: true, attributeFilter: ['data-ov-loading'] });++ }++ if (document.body) watchRoutes(); else document.addEventListener('DOMContentLoaded', watchRoutes, { once: true });++++ window.OVSkeleton = { cards, block, clear, clearAll, ensure, routeShape, ROUTE_SHAPE };})();@@@ -22,8 +22,8 @@ const BUDGETS = {htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1269, // 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)-- jsBrotliKB: 297, // 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ jsRawKB: 1276, // 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsBrotliKB: 299, // 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++// Route placeholders (public/js/ov-skeleton.js; plan L52 step 6): while a route's code loads, its section shows the++// shape of the page on its way instead of an empty 40vh box. Every page section has a shape, every shape draws, a++// phone gets fewer cards than a computer, and nothing in them spins.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'ov-skeleton.js'), 'utf8');++function load(innerWidth) {++ const window = { innerWidth };++ const document = { body: null, addEventListener() {}, getElementById() { return null; } };++ vm.runInNewContext(src, { window, document, setTimeout, clearTimeout });++ return window.OVSkeleton;++}++++const phone = load(390);++const computer = load(1280);++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');++const sections = [...html.matchAll(/<section id="(page-[a-z-]+)"/g)].map((m) => m[1]).filter((id) => id !== 'page-not-found');++assert.ok(sections.length >= 14, `found ${sections.length} page sections`);++for (const id of sections) assert.ok(phone.ROUTE_SHAPE[id], `${id} has a placeholder shape`);++++const kinds = [...new Set(Object.values(phone.ROUTE_SHAPE))];++for (const k of kinds) {++ const out = phone.routeShape(k);++ assert.ok(out.length > 100 && /ovsk-bar/.test(out), `${k} draws`);++}++const count = (s) => (s.match(/ovsk-r-card"/g) || []).length;++assert.strictEqual(count(phone.routeShape('cards')), 3, 'a phone gets three cards');++assert.strictEqual(count(computer.routeShape('cards')), 8, 'a computer gets two rows of four');++assert.ok(/ovsk-r-video/.test(phone.routeShape('player')), 'a channel waits as a 16:9 player and its chat');++assert.ok(!/spin|rotate/.test(src), 'a placeholder shimmers; nothing spins');++console.log(`route skeletons: ${sections.length} sections, ${kinds.length} shapes (${kinds.join(', ')})`);
@@ -108,5 +108,125 @@
@@ -108,5 +108,125 @@
/** Placeholders that are still up after a failed load should just go away. */
/** Placeholders that are still up after a failed load should just go away. */
function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }
function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }
- window.OVSkeleton = { cards, block, clear, clearAll, ensure };
+ // ── Route placeholders (plan L52 step 6; the owner, 11-01: "placeholder elements while it loads, a fake thumbnail,
+ // so it is not jarring") ──
+ // While a route's code loads, the web runtime keeps the section's space and hides what is in it
+ // (section[data-ov-loading] > * { visibility: hidden }). That left an empty 40vh box. Now the box shows the shape of
+ // the page on its way, in the network kit's shapes (kit.skeleton: card, row, tile, chat, line, stat), and only when
+ // the wait is long enough to see (120 ms): a route already loaded never flashes one.
+ const ROUTE_SHAPE = {
+ 'page-home': 'cards', 'page-content': 'cards', 'page-moments': 'cards', 'page-search': 'rows',
+ 'page-channel': 'player', 'page-stream': 'player', 'page-dashboard': 'dash', 'page-broadcast': 'panel',
+ 'page-chat': 'chat', 'page-updates': 'rows', 'page-recap': 'lines', 'page-rundown': 'rows',
+ 'page-documentation': 'lines', 'page-paste-viewer': 'lines', 'page-profile': 'rows',
+ };
+ const ROUTE_CSS = `
+[data-ov-loading] > .ovsk-route { visibility: visible; }
+.ovsk-route { position: absolute; inset: 0 0 auto 0; z-index: 1; pointer-events: none; display: grid; gap: 16px;
+ padding: 20px max(16px, env(safe-area-inset-left, 0px)); max-width: 1280px; margin: 0 auto; box-sizing: border-box; }
+.ovsk-route .ovsk-bar { display: block; }
+.ovsk-r-cards { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
+.ovsk-r-card { display: grid; gap: 8px; }
+.ovsk-r-card .ovsk-thumb { border-radius: 12px; position: relative; overflow: hidden; }
+.ovsk-r-row { display: flex; gap: 12px; align-items: center; }
+.ovsk-r-row .ovsk-av { width: 40px; height: 40px; }
+.ovsk-r-lines { flex: 1; min-width: 0; display: grid; gap: 7px; }
+.ovsk-r-player { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
+@media (min-width: 1000px) { .ovsk-r-player { grid-template-columns: minmax(0, 1fr) 340px; } }
+.ovsk-r-video { aspect-ratio: 16 / 9; border-radius: 14px; }
+.ovsk-r-chat { display: grid; gap: 12px; align-content: start; }
+.ovsk-r-msg { display: flex; gap: 8px; align-items: center; }
+.ovsk-r-msg .ovsk-av { width: 22px; height: 22px; }
+.ovsk-r-stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
+.ovsk-r-stat { height: 72px; border-radius: 12px; }
+.ovsk-r-field { display: grid; gap: 8px; }
+.ovsk-r-input { height: 44px; border-radius: 10px; }
+.ovsk-route.ovsk-out { opacity: 0; transition: opacity .18s ease; }`;
+
+ const bar = (h, w, extra = '') => `<span class="ovsk-bar${extra}" style="height:${h}px;width:${w}%"></span>`;
+ const WIDTHS = [86, 64, 78, 52, 70, 90, 58];
+ function routeShape(kind) {
+ const n = (k) => WIDTHS[k % WIDTHS.length];
+ if (kind === 'cards') {
+ const w = window.innerWidth;
+ const count = w < 600 ? 3 : w < 1000 ? 4 : 8;
+ let h = '';
+ for (let i = 0; i < count; i++) h += `<div class="ovsk-r-card"><span class="ovsk-bar ovsk-thumb"></span>${bar(12, n(i))}${bar(10, n(i + 3) - 20)}</div>`;
+ return `${bar(22, 28)}<div class="ovsk-r-cards">${h}</div>`;
+ }
+ if (kind === 'player') {
+ let chat = '';
+ for (let i = 0; i < 6; i++) chat += `<div class="ovsk-r-msg"><span class="ovsk-bar ovsk-av"></span>${bar(10, n(i))}</div>`;
+ return `<div class="ovsk-r-player"><div style="display:grid;gap:12px"><span class="ovsk-bar ovsk-r-video"></span>${bar(18, 52)}${bar(11, 34)}</div><div class="ovsk-r-chat">${chat}</div></div>`;
+ }
+ if (kind === 'chat') {
+ let chat = '';
+ for (let i = 0; i < 10; i++) chat += `<div class="ovsk-r-msg"><span class="ovsk-bar ovsk-av"></span>${bar(10, n(i))}</div>`;
+ return `${bar(20, 30)}<div class="ovsk-r-chat">${chat}</div>`;
+ }
+ if (kind === 'dash') {
+ let stats = '';
+ for (let i = 0; i < 4; i++) stats += '<span class="ovsk-bar ovsk-r-stat"></span>';
+ return `${bar(22, 30)}<div class="ovsk-r-stats">${stats}</div>${routeShape('rows').replace(/^.*?(<div class="ovsk-r-row")/, '$1')}`;
+ }
+ if (kind === 'panel') {
+ let f = '';
+ for (let i = 0; i < 5; i++) f += `<div class="ovsk-r-field">${bar(11, 22 + (i % 3) * 8)}<span class="ovsk-bar ovsk-r-input"></span></div>`;
+ return `${bar(22, 36)}${f}`;
+ }
+ if (kind === 'lines') {
+ let l = '';
+ for (let i = 0; i < 9; i++) l += bar(11, n(i));
+ return `${bar(24, 44)}${l}`;
+ }
+ let rows = '';
+ for (let i = 0; i < 7; i++) rows += `<div class="ovsk-r-row"><span class="ovsk-bar ovsk-av"></span><span class="ovsk-r-lines">${bar(12, n(i))}${bar(10, n(i + 2) - 24)}</span></div>`;
+ return `${bar(22, 30)}${rows}`;
+ }
+
+ function routeOn(section) {
+ if (section.querySelector(':scope > .ovsk-route')) return;
+ const kind = ROUTE_SHAPE[section.id] || 'lines';
+ ensure();
+ if (!document.getElementById('ovsk-route-css')) {
+ const st = document.createElement('style'); st.id = 'ovsk-route-css'; st.textContent = ROUTE_CSS;
+ document.head.appendChild(st);
+ }
+ const d = document.createElement('div');
+ d.className = `ovsk-route ovsk-route-${kind}`;
+ d.setAttribute('role', 'status');
+ d.setAttribute('aria-busy', 'true');
+ d.setAttribute('aria-label', `Loading ${section.dataset.title || 'the page'}`);
+ d.innerHTML = routeShape(kind);
+ section.prepend(d);
+ }
+ function routeOff(section) {
+ section.querySelectorAll(':scope > .ovsk-route').forEach((d) => {
+ d.classList.add('ovsk-out');
+ setTimeout(() => { try { d.remove(); } catch { /* gone */ } }, 200);
+ });
+ }
+
+ const pending = new WeakMap();
+ function watchRoutes() {
+ if (!window.MutationObserver || watchRoutes.on) return;
+ watchRoutes.on = true;
+ new MutationObserver((list) => {
+ for (const m of list) {
+ const s = m.target;
+ if (!(s instanceof HTMLElement) || !s.classList.contains('page')) continue;
+ if (s.hasAttribute('data-ov-loading')) {
+ if (pending.has(s)) continue;
+ pending.set(s, setTimeout(() => { pending.delete(s); if (s.hasAttribute('data-ov-loading')) routeOn(s); }, 120));
+ } else {
+ clearTimeout(pending.get(s));
+ pending.delete(s);
+ routeOff(s);
+ }
+ }
+ }).observe(document.body || document.documentElement, { subtree: true, attributes: true, attributeFilter: ['data-ov-loading'] });
+ }
+ if (document.body) watchRoutes(); else document.addEventListener('DOMContentLoaded', watchRoutes, { once: true });
+
+ window.OVSkeleton = { cards, block, clear, clearAll, ensure, routeShape, ROUTE_SHAPE };
})();
})();
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1269, // 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
- jsBrotliKB: 297, // 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ jsRawKB: 1276, // 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsBrotliKB: 299, // 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+// Route placeholders (public/js/ov-skeleton.js; plan L52 step 6): while a route's code loads, its section shows the
+// shape of the page on its way instead of an empty 40vh box. Every page section has a shape, every shape draws, a
+// phone gets fewer cards than a computer, and nothing in them spins.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'ov-skeleton.js'), 'utf8');
+function load(innerWidth) {
+ const window = { innerWidth };
+ const document = { body: null, addEventListener() {}, getElementById() { return null; } };
+ vm.runInNewContext(src, { window, document, setTimeout, clearTimeout });
+ return window.OVSkeleton;
+}
+
+const phone = load(390);
+const computer = load(1280);
+const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
+const sections = [...html.matchAll(/<section id="(page-[a-z-]+)"/g)].map((m) => m[1]).filter((id) => id !== 'page-not-found');
+assert.ok(sections.length >= 14, `found ${sections.length} page sections`);
+for (const id of sections) assert.ok(phone.ROUTE_SHAPE[id], `${id} has a placeholder shape`);
+
+const kinds = [...new Set(Object.values(phone.ROUTE_SHAPE))];
+for (const k of kinds) {
+ const out = phone.routeShape(k);
+ assert.ok(out.length > 100 && /ovsk-bar/.test(out), `${k} draws`);
+}
+const count = (s) => (s.match(/ovsk-r-card"/g) || []).length;
+assert.strictEqual(count(phone.routeShape('cards')), 3, 'a phone gets three cards');
+assert.strictEqual(count(computer.routeShape('cards')), 8, 'a computer gets two rows of four');
+assert.ok(/ovsk-r-video/.test(phone.routeShape('player')), 'a channel waits as a 16:9 player and its chat');
+assert.ok(!/spin|rotate/.test(src), 'a placeholder shimmers; nothing spins');
+console.log(`route skeletons: ${sections.length} sections, ${kinds.length} shapes (${kinds.join(', ')})`);