openvibers/openvibe.design Commits
openvibe.design/loading: how OpenVibe waits, every part live (#81)
Changes
nnew file mode 100644@@@ -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); }++}nnew file mode 100644@@@ -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);++ });++})();@@@ -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' },@@@ -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) => {@@@ -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>nnew file mode 100644@@@ -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’s lists. Press <b>Fill in</b>: the real content takes the placeholder’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’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 };nnew file mode 100644@@@ -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);++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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); }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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);
+ });
+})();
@@ -34,6 +34,7 @@ function publicPages(themes = []) {
@@ -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: '/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: '/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: '/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: '/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', 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.' },
{ 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) {
@@ -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: '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: '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: '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 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: '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: '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: '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: '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: '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 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' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
});
});
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
// ── 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('/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', (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/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/:slug', (req, res) => {
r.get('/patterns/:slug', (req, res) => {
@@ -30,6 +30,7 @@ const NAV = [
@@ -30,6 +30,7 @@ const NAV = [
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', 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: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
@@ -45,7 +46,7 @@ const baseUrl = () => BASE;
@@ -45,7 +46,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
/** 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 || '/';
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
const navbar = {
@@ -84,6 +85,7 @@ ${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">
@@ -84,6 +85,7 @@ ${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.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')}
${(scripts || []).map((rel) => `<script src="${esc(asset(rel))}" defer></script>`).join('\n')}
<meta name="ov-boost" content="design@${esc(RELEASE)}">`,
<meta name="ov-boost" content="design@${esc(RELEASE)}">`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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’s lists. Press <b>Fill in</b>: the real content takes the placeholder’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’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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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);
+ }
+})();