openvibers/openvibe.design Commits

openvibe.design/loading: how OpenVibe waits, every part live (#81)

OP OpenVibers committed 12 days ago · d0c59817e9 · 7 files, +292 −2

Plan L52 step 5. Placeholders shaped like the content (kit.skeleton's seven shapes), each in the first HTML with
Fill in: the real content takes its place and the page measures how far it moved (0 px for all seven, phone and
computer). The mark at work (OpenVibeLoading: steps, slow, fail, done) on a live screen; this page's own navbar busy
(OpenVibeNavbar.busy); the rules (drawn first, shapes at once, pictures on their colour, app-like moves, nothing
spins, slow says so, off screen costs nothing); and the code. layout() takes shared: [files] for Shared's browser
scripts. In the menu, the sitemap and llms.txt (which now names the icon MCP tools too).

Changes

ddiff --git a/public/css/loading.css b/public/css/loading.css
nnew file mode 100644
iindex 0000000..c55672f
---- /dev/null
++++ b/public/css/loading.css
@@@ -0,0 +1,64 @@
++/* /loading (plan L52 step 5): how OpenVibe waits. Theme variables from the Frame; the placeholders are kit.css's. */
++.ld { --ld-face: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; max-width: 1120px; margin: 0 auto; padding: clamp(18px, 4vw, 44px) clamp(16px, 3vw, 32px) 96px; color: var(--text-primary); }
++.ld-hero { max-width: 780px; padding-bottom: clamp(18px, 3vw, 28px); }
++.ld-eye { margin: 0 0 10px; color: var(--accent); font-size: 12px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
++.ld-h1 { margin: 0; font-family: var(--ld-face); font-weight: 900; font-size: clamp(32px, 5.2vw, 56px); line-height: 1.05; letter-spacing: -.02em; text-wrap: balance; }
++.ld-lede { margin: 14px 0 0; max-width: 62ch; color: var(--text-secondary); font-size: clamp(15px, 1.6vw, 17px); line-height: 1.6; }
++.ld-jump { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 18px; font-size: 14px; }
++.ld-jump a { color: var(--text-secondary); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
++.ld-jump a:hover { color: var(--text-primary); }
++
++.ld-sec { margin: 0; padding: clamp(22px, 3.5vw, 38px) 0; border-top: 1px solid var(--border); }
++.ld-sec-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 8px 32px; align-items: baseline; margin-bottom: 18px; }
++.ld-sec-head h2 { margin: 0; font-family: var(--ld-face); font-weight: 900; font-size: clamp(22px, 3vw, 30px); line-height: 1.15; text-wrap: balance; }
++.ld-sec-head p { margin: 0; max-width: 62ch; color: var(--text-secondary); line-height: 1.55; }
++.ld h3 { margin: 0 0 10px; font-size: 14px; font-weight: 750; }
++
++/* The seven shapes, each with Fill in and the measured move. */
++.ld-demos { list-style: none; margin: 0; padding: 0; display: grid; align-items: start; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); gap: 12px; }
++.ld-demo { display: grid; gap: 10px; align-content: start; padding: 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--bg-secondary); min-width: 0; }
++.ld-demo[data-ld-kind="card"] { grid-column: 1 / -1; }
++.ld-demo-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
++.ld-demo-head b { font-size: 15px; }
++.ld-demo > p { margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.5; }
++.ld-fill { min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-primary); color: var(--text-primary); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
++.ld-fill[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: var(--accent); }
++.ld-fill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.ld-stage { min-width: 0; }
++.ld-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; font-size: 12px; color: var(--text-muted); }
++.ld-foot code { font-size: 12px; }
++.ld-foot output { font-weight: 750; font-variant-numeric: tabular-nums; }
++.ld-foot output.is-zero { color: #22c55e; }
++
++/* The real content, at the placeholders' exact sizes (kit.css .ovk-skel-*): filling in moves nothing. */
++.ld-thumb, .ld-tile, .ld-avatar, .ld-dot { display: block; background: linear-gradient(135deg, hsl(var(--h, 220) 70% 55%), hsl(calc(var(--h, 220) + 40) 70% 40%)); }
++.ld-thumb { aspect-ratio: 16 / 9; border-radius: 12px; }
++.ld-l1, .ld-l2, .ld-line { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.ld-l1 { height: 13px; font-size: 13px; line-height: 13px; font-weight: 650; }
++.ld-l2 { height: 11px; font-size: 11px; line-height: 11px; color: var(--text-muted); }
++.ld-avatar { flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 15px; }
++.ld-tile { aspect-ratio: 1; border-radius: 14px; }
++.ld-chip { display: inline-grid; place-items: center; width: 84px; height: 30px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text-primary); font-size: 13px; font-weight: 650; }
++.ld-line { height: 12px; font-size: 12px; line-height: 12px; color: var(--text-secondary); }
++.ld-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
++.ld-real .ld-l1 b { color: var(--accent); }
++.ld-num { display: block; height: 22px; font-size: 21px; line-height: 22px; font-weight: 850; font-variant-numeric: tabular-nums; }
++
++/* The mark at work. */
++.ld-mark { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 16px; align-items: center; margin-bottom: 14px; }
++.ld-screen { position: relative; aspect-ratio: 16 / 9; border-radius: 16px; overflow: hidden; background: #05070c; display: grid; place-items: center; }
++.ld-screen-idle { color: #8e99b0; font-size: 14px; }
++.ld-ctrls { display: flex; flex-wrap: wrap; gap: 8px; }
++.ld-navdemo { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-bottom: 14px; }
++.ld-navdemo span { color: var(--text-secondary); font-size: 14px; }
++
++/* The rules and the code. */
++.ld-rules { margin: 0; padding-left: 1.3em; display: grid; gap: 12px; max-width: 78ch; line-height: 1.6; color: var(--text-secondary); }
++.ld-rules b { color: var(--text-primary); }
++.ld-use { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
++.ld-use > div { min-width: 0; }
++.ld-use pre { font-size: 12.5px; white-space: pre; overflow-x: auto; padding-right: 52px; }
++
++@media (max-width: 860px) {
++ .ld-sec-head, .ld-mark, .ld-use { grid-template-columns: minmax(0, 1fr); }
++}
ddiff --git a/public/js/loading-page.js b/public/js/loading-page.js
nnew file mode 100644
iindex 0000000..686e7aa
---- /dev/null
++++ b/public/js/loading-page.js
@@@ -0,0 +1,66 @@
++/**
++ * /loading's demos (server/views/loading.js): Fill in swaps each placeholder for its real content and measures how far
++ * the page below moved; the mark at work runs openvibe-shared's OpenVibeLoading; and the navbar shows its busy state.
++ * Without this script the page still shows every placeholder and every snippet.
++ */
++(function () {
++ 'use strict';
++ const $$ = (s, el = document) => Array.prototype.slice.call(el.querySelectorAll(s));
++
++ // Fill in: the same place, the same size. The number is the height the stage changed by, which is how far
++ // everything under it moved.
++ $$('[data-ld-kind]').forEach((demo) => {
++ const stage = demo.querySelector('[data-ld-stage]');
++ const real = demo.querySelector('[data-ld-real]');
++ const btn = demo.querySelector('[data-ld-fill]');
++ const out = demo.querySelector('[data-ld-moved]');
++ if (!stage || !real || !btn) return;
++ const waiting = stage.innerHTML;
++ btn.addEventListener('click', () => {
++ const fill = btn.getAttribute('aria-pressed') !== 'true';
++ const before = stage.getBoundingClientRect().height;
++ stage.innerHTML = fill ? real.innerHTML : waiting;
++ const moved = Math.round(Math.abs(stage.getBoundingClientRect().height - before) * 10) / 10;
++ btn.setAttribute('aria-pressed', fill ? 'true' : 'false');
++ btn.textContent = fill ? 'Back to waiting' : 'Fill in';
++ if (out) {
++ out.textContent = fill ? `Moved ${moved} px` : '';
++ out.classList.toggle('is-zero', fill && moved === 0);
++ }
++ });
++ });
++
++ // The mark at work.
++ const screen = document.querySelector('[data-ld-screen]');
++ const STEPS = ['Connecting', 'Joining', 'Buffering', 'Starting'];
++ let loader = null;
++ let step = 0;
++ const start = () => {
++ if (!window.OpenVibeLoading || !screen) return;
++ if (loader) loader.destroy();
++ const idle = screen.querySelector('.ld-screen-idle');
++ if (idle) idle.remove();
++ step = 0;
++ loader = window.OpenVibeLoading.mount(screen, { poster: '/icons/live.png?size=512&color=mark&ground=square&bg=18233d&pad=30', label: STEPS[0], sub: 'Getting your viewer ready', steps: STEPS.length });
++ };
++ $$('[data-ld-do]').forEach((b) => b.addEventListener('click', () => {
++ const what = b.getAttribute('data-ld-do');
++ if (what === 'start') return start();
++ if (!loader) start();
++ if (!loader) return;
++ if (what === 'step') { step = Math.min(STEPS.length - 1, step + 1); loader.update({ step, label: STEPS[step] }); }
++ if (what === 'slow') loader.slow({ hint: 'Still connecting: the stream is a long way off', actions: [{ label: 'Try again', onClick: start }] });
++ if (what === 'fail') loader.fail({ title: 'The stream ended', detail: 'The broadcaster went offline a moment ago.', actions: [{ label: 'Retry', onClick: start }] });
++ if (what === 'done') { loader.done(); loader = null; }
++ }));
++
++ // Between pages: this page's own navbar, for two and a half seconds.
++ const busy = document.querySelector('[data-ld-busy]');
++ if (busy) busy.addEventListener('click', () => {
++ const nav = window.OpenVibeNavbar;
++ if (!nav || typeof nav.busy !== 'function') return;
++ nav.busy(true);
++ busy.disabled = true;
++ setTimeout(() => { nav.busy(false); busy.disabled = false; }, 2500);
++ });
++})();
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 006051b..7e93bff 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -34,6 +34,7 @@ function publicPages(themes = []) {
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
++ { path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
@@@ -86,13 +87,14 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },
++ { title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Machine-readable', links: [
-- { title: 'MCP tools', url: abs('/mcp'), note: 'POST, Streamable HTTP; public themes, tokens and components' },
++ { title: 'MCP tools', url: abs('/mcp'), note: 'POST, Streamable HTTP; public themes, tokens, components and the icons (search, SVG, code in any format, a migration from Font Awesome, Lucide or Material)' },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cb5c51c..920da36 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
});
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
++ r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
r.get('/patterns/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/:slug', (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b302353..2b779d7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -30,6 +30,7 @@ const NAV = [
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },
++ { label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
@@@ -45,7 +46,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
--function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt } = {}) {
++function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt, shared = [] } = {}) {
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
@@@ -84,6 +85,7 @@ ${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
++${(shared || []).map((file) => `<script src="${esc(ovServe.url(file))}" defer></script>`).join('\n')}
${(scripts || []).map((rel) => `<script src="${esc(asset(rel))}" defer></script>`).join('\n')}
<meta name="ov-boost" content="design@${esc(RELEASE)}">`,
body: `<a class="skip" href="#main">Skip to content</a>
ddiff --git a/server/views/loading.js b/server/views/loading.js
nnew file mode 100644
iindex 0000000..d7caf8b
---- /dev/null
++++ b/server/views/loading.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * /loading: how OpenVibe waits (plan L52 step 5; the owner, 11-01: "async loading… placeholder elements while it loads,
++ * a fake thumbnail… so it is not jarring… the logo icon changing while it loads, a custom indicator… part of
++ * openvibe.design… the whole package"). Every part live and copyable, from openvibe-shared:
++ * - placeholders shaped like the content (kit.skeleton), each with "Fill in", which swaps it for real content of the
++ * same shape and measures how far the page moved (it should be 0 px);
++ * - the mark at work (OpenVibeLoading: steps, slow, fail, done);
++ * - the navigation indicator (OpenVibeNavbar.busy), on this page's own navbar;
++ * - the rules.
++ * Server-rendered: the placeholders are in the first HTML, as they should be anywhere. js/loading-page.js runs the demos.
++ *
++ * loadingPage({ base })
++ */
++const kit = require('openvibe-shared/kit');
++const { layout, esc } = require('../render/layout');
++
++// Real content of each placeholder's shape, at its sizes (kit.css .ovk-skel-*), so filling in moves nothing.
++const FILLED = {
++ card: (i) => `<div class="ovk-skel-card ld-real"><span class="ld-thumb" style="--h:${[212, 268, 32][i % 3]}"></span><span class="ld-l1">${['Building a rocket from scratch', 'Late night chill and chat', 'Speedrun practice'][i % 3]}</span><span class="ld-l2">${['iZoR_One · 1.2k watching', 'mia · 340 watching', 'kai · 88 watching'][i % 3]}</span></div>`,
++ row: (i) => `<div class="ovk-skel-row ld-real"><span class="ld-avatar" style="--h:${[212, 32, 150][i % 3]}">${['A', 'M', 'K'][i % 3]}</span><span class="ovk-skel-lines"><span class="ld-l1">${['Ana', 'Mia', 'Kai'][i % 3]}</span><span class="ld-l2">${['Live now · Just chatting', 'Offline · 2h ago', 'Live now · Games'][i % 3]}</span></span></div>`,
++ tile: (i) => `<span class="ld-tile" style="--h:${(i * 47 + 190) % 360}"></span>`,
++ chip: (i) => `<span class="ld-chip">${['Music', 'Games', 'Art', 'Talk', 'Code', 'IRL'][i % 6]}</span>`,
++ line: (i) => `<span class="ld-line">${['The first line of a paragraph that arrives a moment later.', 'And another, a little shorter.', 'A third, the shortest.'][i % 3]}</span>`,
++ chat: (i) => `<div class="ovk-skel-chat ld-real"><span class="ld-dot" style="--h:${[150, 32, 270][i % 3]}"></span><span class="ld-l1"><b>${['mia', 'kai', 'ana'][i % 3]}</b> ${['that was so clean', 'lol same', 'welcome back!'][i % 3]}</span></div>`,
++ stat: (i) => `<div class="ovk-skel-stat ld-real"><span class="ld-num">${['1,284', '96%', '12h'][i % 3]}</span><span class="ld-l2">${['viewers today', 'went smoothly', 'streamed this week'][i % 3]}</span></div>`,
++};
++const SHAPES = [
++ ['card', 'Card', 'A video or stream: its 16:9 picture and two lines.', 3],
++ ['row', 'Row', 'A person or a result: an avatar and two lines.', 3],
++ ['tile', 'Tile', 'A square in a grid: an emote, an icon, a cover.', 6],
++ ['chip', 'Chip', 'A tag or a filter.', 6],
++ ['line', 'Line', 'Text that is on its way.', 3],
++ ['chat', 'Chat', 'A chat message: a dot and a line.', 3],
++ ['stat', 'Stat', 'A number and what it counts.', 3],
++];
++
++function loadingPage({ base = 'https://openvibe.design' } = {}) {
++ const demo = ([kind, name, says, n]) => `<li class="ld-demo" data-ld-kind="${kind}">
++ <div class="ld-demo-head"><b>${esc(name)}</b><button type="button" class="ld-fill" data-ld-fill aria-pressed="false">Fill in</button></div>
++ <p>${esc(says)}</p>
++ <div class="ld-stage" data-ld-stage>${kit.skeleton({ kind, count: n, label: `Loading ${name.toLowerCase()}s` })}</div>
++ <template data-ld-real><div class="ovk-skels ovk-skels-${kind}">${Array.from({ length: n }, (_, i) => FILLED[kind](i)).join('')}</div></template>
++ <div class="ld-foot"><code>kit.skeleton({ kind: '${kind}', count: ${n} })</code><output data-ld-moved aria-live="polite"></output></div>
++</li>`;
++
++ const RULES = [
++ ['The page arrives drawn.', 'Server-rendered HTML with its icons already drawn (no icon font to wait for), and only the CSS the first screen needs before it paints.'],
++ ['A list shows its shape at once.', 'Its placeholders are in the first HTML, the same size as what replaces them, so filling in moves nothing (each demo above measures it).'],
++ ['A picture sits on its own colour first.', 'Its dominant colour, or a tiny blurred copy, until it arrives. The first picture asks for itself first (fetchpriority="high"); the rest wait until they are near (loading="lazy").'],
++ ['Moving between pages feels like one app.', 'The mark works in the navbar with a two-pixel line under it. Links are fetched when pointed at (speculation rules), and the navbar and app bar stay still while the page cross-fades (view transitions).'],
++ ['Nothing spins.', 'The mark bobs and breathes, a line fills. Calm and reduced motion keep everything still.'],
++ ['Slow says so.', 'After a few seconds a load explains itself and offers what to do. A failure says what happened and offers Retry, never an endless wait.'],
++ ['Off screen costs nothing.', 'Long lists skip the work for what is not on screen yet (content-visibility: auto).'],
++ ];
++
++ const body = `<article class="ld">
++<header class="ld-hero">
++ <p class="ld-eye">Loading</p>
++ <h1 class="ld-h1">Nothing spins. Everything has its shape.</h1>
++ <p class="ld-lede">How OpenVibe waits: the page arrives drawn, a list shows its own shape until it fills in, the mark works while a page moves, and a slow load says so. The same parts on every site, from <code>openvibe-shared</code>.</p>
++ <nav class="ld-jump" aria-label="On this page"><a href="#shapes">Placeholders</a><a href="#mark">The mark at work</a><a href="#nav">Between pages</a><a href="#rules">The rules</a><a href="#use">Use it</a></nav>
++</header>
++
++<section class="ld-sec" id="shapes" aria-labelledby="ld-shapes">
++ <div class="ld-sec-head"><h2 id="ld-shapes">Placeholders shaped like the content</h2><p>Seven shapes cover the network&rsquo;s lists. Press <b>Fill in</b>: the real content takes the placeholder&rsquo;s place, and the page says how far everything below it moved.</p></div>
++ <ul class="ld-demos">${SHAPES.map(demo).join('')}</ul>
++</section>
++
++<section class="ld-sec" id="mark" aria-labelledby="ld-mark">
++ <div class="ld-sec-head"><h2 id="ld-mark">The mark at work</h2><p>For a stream or a player that takes a moment: its own picture blurred behind, the V bobbing and the O breathing, one word of status and a line that fills with each step. Slow, it explains; failed, it says what happened.</p></div>
++ <div class="ld-mark">
++ <div class="ld-screen" data-ld-screen><span class="ld-screen-idle">Press Start</span></div>
++ <div class="ld-ctrls" role="group" aria-label="The loader">
++ <button type="button" class="ic-btn is-go" data-ld-do="start">Start</button>
++ <button type="button" class="ic-btn" data-ld-do="step">Next step</button>
++ <button type="button" class="ic-btn" data-ld-do="slow">It is slow</button>
++ <button type="button" class="ic-btn" data-ld-do="fail">It failed</button>
++ <button type="button" class="ic-btn" data-ld-do="done">Done</button>
++ </div>
++ </div>
++ ${kit.snippet({ code: `const l = OpenVibeLoading.mount(player, { poster, label: 'Connecting', sub: 'Getting your viewer ready', steps: 4 });\nl.update({ step: 1, label: 'Joining' });\nl.slow({ hint: 'Still connecting', actions: [{ label: 'Try again', onClick: retry }] });\nl.fail({ title: 'The stream ended', actions: [{ label: 'Retry', onClick: retry }] });\nl.done();`, id: 'ld-mark-code', label: 'Copy' })}
++</section>
++
++<section class="ld-sec" id="nav" aria-labelledby="ld-nav">
++ <div class="ld-sec-head"><h2 id="ld-nav">Between pages</h2><p>While a page or a route loads, the mark in the navbar works and a two-pixel line runs under it; it settles when the page is in. boost.js and the route loaders call it for you; anything else can too.</p></div>
++ <div class="ld-navdemo"><button type="button" class="ic-btn is-go" data-ld-busy>Show it on this navbar</button><span data-ld-busy-note>Look at the mark, top left.</span></div>
++ ${kit.snippet({ code: 'OpenVibeNavbar.busy(true); // the mark works, the line runs\n// …the page or route arrives…\nOpenVibeNavbar.busy(false);', id: 'ld-nav-code', label: 'Copy' })}
++</section>
++
++<section class="ld-sec" id="rules" aria-labelledby="ld-rules">
++ <div class="ld-sec-head"><h2 id="ld-rules">The rules</h2><p>What every OpenVibe site does while you wait. Live&rsquo;s measurements are in <a href="https://openvibe.live/docs/performance-audit">its performance audit</a>.</p></div>
++ <ol class="ld-rules">${RULES.map(([h, p]) => `<li><b>${esc(h)}</b> ${esc(p)}</li>`).join('')}</ol>
++</section>
++
++<section class="ld-sec" id="use" aria-labelledby="ld-use">
++ <div class="ld-sec-head"><h2 id="ld-use">Use it</h2><p>On an OpenVibe site the navbar brings all of it. Anywhere else, the files are free (MIT).</p></div>
++ <div class="ld-use">
++ <div><h3>Placeholders, on the server</h3>${kit.snippet({ code: "const kit = require('openvibe-shared/kit');\nkit.skeleton({ kind: 'card', count: 6, label: 'Loading streams' });\n// card · row · tile · chip · line · chat · stat", id: 'ld-use-skel', label: 'Copy' })}</div>
++ <div><h3>The loader, in a page</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/kit.css">\n<script src="${base}/shared/loading.js" defer></script>`, id: 'ld-use-load', label: 'Copy' })}</div>
++ <div><h3>App-like page moves</h3>${kit.snippet({ code: `<script src="${base}/shared/boost.js" defer></script>\n<meta name="ov-boost" content="mysite@1">`, id: 'ld-use-boost', label: 'Copy' })}</div>
++ </div>
++</section>
++</article>`;
++ return layout({
++ title: 'Loading: nothing spins',
++ description: 'How OpenVibe waits: placeholders shaped like the content, the mark at work instead of a spinner, the navbar while a page moves, and the rules. Live demos and the code, free.',
++ path: '/loading', active: '/loading', styles: ['css/icons.css', 'css/loading.css'], scripts: ['js/loading-page.js'], shared: ['loading.js'], body,
++ });
++}
++
++module.exports = { loadingPage, SHAPES };
ddiff --git a/test/loading-page.test.js b/test/loading-page.test.js
nnew file mode 100644
iindex 0000000..25f776f
---- /dev/null
++++ b/test/loading-page.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// /loading (plan L52 step 5): how OpenVibe waits, every part live and copyable. The placeholders are in the first HTML
++// (as anywhere), each with its real content of the same shape; the page loads the shared loader; it is in the menu,
++// the sitemap and llms.txt.
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const { SHAPES } = require('../server/views/loading');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the page: seven shapes in the first HTML, each with its real content and Fill in; the loader; the rules', async () => {
++ const r = await t.get('/loading');
++ assert.equal(r.status, 200);
++ const html = r.text;
++ assert.deepEqual(SHAPES.map(([k]) => k), ['card', 'row', 'tile', 'chip', 'line', 'chat', 'stat']);
++ SHAPES.forEach(([kind]) => {
++ assert.match(html, new RegExp(`<div class="ovk-skels ovk-skels-${kind}" role="status" aria-busy="true"`), `${kind}: its placeholder, server-rendered`);
++ assert.match(html, new RegExp(`<template data-ld-real><div class="ovk-skels ovk-skels-${kind}">`), `${kind}: its real content`);
++ });
++ assert.equal((html.match(/data-ld-fill aria-pressed="false">Fill in</g) || []).length, 7);
++ assert.match(html, /\/shared\/loading\.js/, 'the shared loader');
++ assert.match(html, /\/js\/loading-page\.js/);
++ assert.ok(html.includes('data-ld-do="start"') && html.includes('data-ld-busy'));
++ assert.ok(html.includes('Nothing spins.') && html.includes('content-visibility: auto'), 'the rules');
++ assert.ok(!/fa-spin|class="[^"]*spinner/i.test(html), 'no spinner anywhere');
++ });
++ await check('the page script: Fill in measures the move, the loader runs its states, the navbar shows busy', async () => {
++ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'loading-page.js'), 'utf8');
++ assert.match(js, /const moved = Math\.round\(Math\.abs\(stage\.getBoundingClientRect\(\)\.height - before\) \* 10\) \/ 10;/, 'how far the page moved');
++ ['update(', 'slow(', 'fail(', 'done()'].forEach((m) => assert.ok(js.includes(`loader.${m}`), m));
++ assert.match(js, /window\.OpenVibeLoading\.mount\(screen/);
++ assert.match(js, /nav\.busy\(true\)[\s\S]*nav\.busy\(false\)/);
++ });
++ await check('found: the menu, the sitemap and llms.txt', async () => {
++ assert.ok((await t.get('/')).text.includes('/loading'), 'the menu');
++ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.design/loading'));
++ assert.ok((await t.get('/llms.txt')).text.includes('/loading'));
++ });
++ } finally {
++ await done(t);
++ }
++})();