openvibers/openvibe.media Commits
The home on a phone: the code samples are one pick block, the rest are folds (#235)
Changes
@@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const kit = require('openvibe-shared/kit');++const { pickBlock, pickRules, foldBlock } = require('./short');const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));@@@ -267,6 +268,42 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}@media (pointer: coarse){.faq summary{min-height:44px}}++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one at a time,++ picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px++ and only where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++.pick-head{display:none}++.pick-row{display:none}++.pick-pane{min-width:0}++@supports selector(:has(*)){@media (max-width:640px){++ .pick-head{display:block}++ .pick-head h2{margin:0 0 4px;font-size:1.25rem}++ .pick-row{display:flex;gap:8px;overflow-x:auto;margin:10px -16px 0;padding:2px 16px 4px;scrollbar-width:none;overscroll-behavior-x:contain}++ .pick-row::-webkit-scrollbar{display:none}++ .pick-row input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}++ .pick-row label{display:inline-flex;align-items:center;flex:none;min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--line);color:var(--muted);font-size:.9rem;font-weight:700;white-space:nowrap;cursor:pointer}++ .pick-row input:checked+label{color:var(--text);border-color:var(--acc);background:color-mix(in srgb,var(--acc) 12%,transparent)}++ .pick-row input:focus-visible+label{outline:2px solid var(--acc);outline-offset:2px}++ .pick-pane{display:none}++ /* The section's own heading stays (its text comes before it otherwise); the pick block's second heading gives way. */++ .md-pick-sec .pick-head h2{display:none}++}}++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it++ replaces: a heading and its content, with no card around it. */++.md-fold-block{margin:32px 0}++.md-fold-block>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:48px;padding:0 16px;cursor:pointer;color:var(--text);font-weight:700;list-style:none;border:1px solid var(--line);border-radius:14px;background:var(--panel)}++.md-fold-block>summary::-webkit-details-marker{display:none}++.md-fold-block>summary h2{margin:0;padding:0;border:0;font-size:1.05rem}++.md-fold-block>summary span{color:var(--muted);font-weight:600;font-size:.86rem;text-align:right}++.md-fold-block>summary:focus-visible{outline:2px solid var(--acc);outline-offset:-2px;border-radius:14px}++.md-fold-block .sc-sec{margin:8px 0 0}++.md-fold-block .sc-sec>h2{display:none}++.md-fold-block .ov-shipped-home{margin:8px 0 0 !important;padding:0 !important}++@media (min-width:641px){++ .md-fold-block{border:0;background:none;margin-top:40px}++ .md-fold-block>summary{display:block;min-height:0;padding:0;border:0;background:none}++ .md-fold-block>summary h2{margin:0 0 6px;font-size:1.25rem;border-bottom:1px solid var(--line);padding-bottom:.2em}++ .md-fold-block>summary span{display:none}++}`;const DESCRIPTION = 'Videos, clips, pictures and files for every OpenVibe site, and the same API for your apps: objects, S3, thumbnails in any size and HLS.';@@@ -286,12 +323,14 @@ function homeFaqJsonLd() {}const bytes = (n) => { const v = Number(n) || 0; return v >= 1024 ** 3 ? `${+(v / 1024 ** 3).toFixed(1)} GB` : `${Math.round(v / 1024 ** 2)} MB`; };--/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions. */--function developerSections() {++/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions.++ * On a phone the parts read once are folds and the code samples are chips (the owner's short-pages rule, 2026-10-30);++ * every part stays in the page, and a wider screen shows them all. */++function developerSections(rules) {const L = require('../limits').limitsOf(config).limits;const rows = L.filter((l) => ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].includes(l.id)).map((l) => ({ label: l.label, values: [l.unit === 'bytes' ? bytes(l.production) : `${l.production} ${l.unit}`, l.unit === 'bytes' ? bytes(l.sandbox) : `${l.sandbox} ${l.unit}`] }));-- return showcase.features({++ const what = showcase.features({id: 'what',title: 'What it does',lede: 'The media layer under every OpenVibe site, open to your apps through the same API.',@@@ -303,28 +342,41 @@ function developerSections() {{ icon: 'ov:camera', title: 'Live frames', text: '/live/@<name>/frame.jpg: a near-real-time picture of a live stream, for embeds and bots.' },{ icon: 'ov:check', title: 'Limits in public', text: 'Every limit Media enforces is published at /limits.json, the same numbers OpenVibe.Services and Zone show.', href: '/limits.json' },],-- }) + showcase.code({-- id: 'api',-- title: 'From your app',-- lede: 'Public media needs no key. Your own objects take a project token or a Zone key.',-- samples: [-- { label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt="">++ });++ // The code samples are blocks of the same kind: on a phone a row of chips picks one and the chip names it.++ const samples = [++ { id: 'thumbnail', label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt=""><img src="${config.publicUrl}/live/@goosely/frame.jpg?w=640" alt="goosely, live now">` },-- { label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');++ { id: 'node', label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');// tokens: createServiceTokenClient() for your developer app; app is your project idconst objects = createObjectsClient({ app: 'prj_…', tokenClient: tokens });const obj = await objects.upload(fs.readFileSync('q3.pdf'), { kind: 'file', mimeType: 'application/pdf', filename: 'q3.pdf' });const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },-- { label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },-- ],-- }) + showcase.limits({++ { id: 's3', label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },++ ];++ const panes = samples.map((c) => ({++ id: c.id,++ label: c.label,++ html: `<figure><figcaption>${esc(c.label)}</figcaption><pre tabindex="0" aria-label="${esc(c.label)}"><code class="language-${esc(c.lang)}">${esc(c.code)}</code></pre></figure>`,++ }));++ if (rules) rules.push(pickRules('api', panes.map((p) => p.id)));++ const api = `<section class="sc-sec md-pick-sec" id="api" aria-labelledby="api-h"><h2 id="api-h">From your app</h2>++<p class="sc-lede">Public media needs no key. Your own objects take a project token or a Zone key.</p>++${pickBlock({ key: 'api', label: 'The ways to call Media', head: 'From your app', bodyClass: 'sc-code', panes })}++</section>`;++ const limits = showcase.limits({title: 'Limits',lede: 'What one project gets in each environment.',columns: ['Production', 'Sandbox'],rows,source: `${config.publicUrl}/limits.json`,-- }) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;++ });++ const questions = `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2>Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;++ return foldBlock({ id: 'what', head: '<h2 id="what-h">What it does</h2>', html: what })++ + api++ + foldBlock({ id: 'limits', head: '<h2 id="limits-h">Limits</h2>', html: limits })++ + foldBlock({ id: 'questions', head: '<h2 id="faq-h">Questions</h2>', html: questions });}// The kit card's type by media kind (its shape and its icon when there is no picture).@@@ -333,6 +385,8 @@ const asCard = (c) => kit.card({ href: c.view, title: c.title || c.kind, type: Cfunction renderPage(tab, page, data, counts, { home = false } = {}) {const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));++ // The rules that show the picked block of the pick block are written for this page.++ const rules = [];const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;// The front page is "/" itself; "/?tab=all" (and every other tab and page) is the index.const first = home;@@@ -362,15 +416,15 @@ ${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}<<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>`;const body = `${heading}${first ? `<section class="sc-sec sc-media">${kit.rail({ id: 'media', wrap: true, title: 'Latest public media', more: { href: '/?tab=all', label: `See all ${data.total.toLocaleString('en-US')}` }, items: data.cards.slice(0, 12).map(asCard) })}<p class="sc-kinds" aria-label="Browse by kind">${TABS.filter(([k]) => k !== 'all' && (counts[k] ?? 0) > 0).map(([k, label]) => kit.button({ href: `/?tab=${k}`, label: `${label} ${(counts[k] ?? 0).toLocaleString('en-US')}`, kind: 'quiet', small: true })).join(' ')}</p></section>` : list}--${first ? developerSections() : ''}--${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;++${first ? developerSections(rules) : ''}++${first ? foldBlock({ id: 'shipped', head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Media</h2>', html: frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) }) : ''}`;return pageFrame.page({seo: {title: TITLE, description: DESCRIPTION, canonical,robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],},-- css: BROWSE_CSS,++ css: BROWSE_CSS + (rules.length ? `@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}}` : ''),kit: first,body,// Later pages are noindex and carry no structured data, the summary's WebPage included.nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.++ *++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio++ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a++ * browser without :has()) shows them all and no chips.++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow++ * screen and remembers the reader's choice.++ *++ * The CSS for both lives in public/css/app.css; the rule that shows the picked block is written for the page,++ * because there is one per block.++ */++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * A pick block. The panes are the page's own blocks; the block's heading gives way to the chip that names it on a++ * phone, and the chips are gone on a wide screen, where every pane shows at once.++ *++ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {++ if (!panes || !panes.length) return '';++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');++ const heading = `<h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2>`;++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;++}++++/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */++function pickRules(key, ids) {++ return ids.map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`).join('');++}++++/**++ * A fold. The head is the heading the block had; the html is what it holds.++ *++ * foldBlock({ id, head, html })++ */++function foldBlock({ id, head, html }) {++ return `<details class="md-fold md-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++function foldScript(id) {++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;++}++++module.exports = { pickBlock, pickRules, foldBlock, foldScript };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// The home on a phone (the owner's short-pages rule, 2026-10-30): the code samples are one pick block, a row of chips++// picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the chips;++// every block stays in the page for crawlers; a wider screen shows them all and no chips.++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++++(async () => {++ const tmp = path.join(os.tmpdir(), `ov-media-short-${process.pid}.db`);++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ process.env.MEDIA_PGLITE_DIR = path.join(os.tmpdir(), `ov-media-short-${process.pid}-pglite`);++++ const db = require('../server/db/database');++ await db.initDb();++++ const express = require('express');++ const app = express();++ app.use('/', require('../server/public/routes'));++ const server = app.listen(0);++ const port = server.address().port;++ const get = (p) => new Promise((resolve, reject) => {++ http.get({ host: '127.0.0.1', port, path: p, headers: { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' } }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (c) => { body += c; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ }).on('error', reject);++ });++++ try {++ const r = await get('/');++ assert.strictEqual(r.status, 200);++ const text = r.body;++++ // The code samples: one pick block, a chip per sample, the first one picked.++ assert.ok(text.includes('<div class="pick" id="pick-api">'), 'the code samples are one pick block');++ assert.match(text, /<div class="pick-row" role="radiogroup" aria-label="The ways to call Media">/);++ const chips = [...text.matchAll(/<input type="radio" name="pick-api" id="pk-api-([a-z0-9-]+)" value="\1"( checked)?><label for="pk-api-\1">([^<]+)<\/label>/g)];++ assert.deepStrictEqual(chips.map((m) => m[1]), ['thumbnail', 'node', 's3'], 'a chip per sample');++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['thumbnail'], 'the first sample is picked');++ assert.strictEqual((text.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');++ // Every sample stays in the page for the crawlers.++ for (const label of ['Thumbnail', 'Node (openvibe-sdk)', 'S3 (any tool)']) {++ assert.ok(text.includes(`<figcaption>${label}</figcaption>`), `${label} is in the page`);++ }++ // The rule that shows the picked sample is written for this page.++ assert.ok(text.includes('.pick:has(#pk-api-thumbnail:checked) #pp-api-thumbnail{display:block}'), 'the picked sample shows');++++ // The folds: open in the page, closed on a phone, and their content stays in the page.++ for (const id of ['what', 'limits', 'questions', 'shipped']) {++ assert.ok(text.includes(`<details class="md-fold md-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);++ }++ assert.ok(text.includes('Objects and S3'), 'what it does is still in the page');++ assert.ok(text.includes('sc-limits'), 'the limits table is still in the page');++ assert.ok(text.includes('Do I need an account to watch?'), 'the questions are still in the page');++ assert.ok(text.includes('ov-shipped-home'), 'what shipped is still in the page');++++ // The hero and the first useful thing stay where they are.++ assert.match(text, /<h1[\s>]/, 'the hero is still there');++ assert.ok(text.includes('Browse public media'), 'the newest media is still the first thing to reach');++ assert.ok(text.includes('class="sc-sec sc-media"'), 'the newest media rail is still there');++ assert.ok(text.includes('id="api"'), 'the code samples are still there, under their own anchor');++++ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.++ const css = fs.readFileSync(path.join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');++ assert.match(css, /\.pick-row\{display:none\}/, 'the chips are hidden by default');++ assert.match(css, /@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{/, 'the phone rules are under :has() and 640 px');++ assert.match(css, /\.pick-row label\{[^}]*min-height:44px/, 'a chip is a 44 px target');++ assert.match(css, /\.pick-pane\{display:none\}/, 'the blocks are hidden on a phone');++++ console.log('home short: all checks passed');++ } finally {++ server.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* nothing to clean */ } }++ }++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');(async () => {const BUDGETS = {-- htmlRawKB: 48, // 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before++ htmlRawKB: 50, // 48.6 with the phone pick block and folds (2026-10-30; a fresh database measures a little larger); 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 beforehtmlBrotliKB: 12, // 10.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 9.7 on 2026-10-22; 5.0 beforejsFiles: 4, // 3// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const { pickBlock, pickRules, foldBlock } = require('./short');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
@@ -267,6 +268,42 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
@@ -267,6 +268,42 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}
.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}
.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}
.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}
@media (pointer: coarse){.faq summary{min-height:44px}}
@media (pointer: coarse){.faq summary{min-height:44px}}
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the home's blocks of the same kind are one at a time,
+ picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px
+ and only where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+.pick-head{display:none}
+.pick-row{display:none}
+.pick-pane{min-width:0}
+@supports selector(:has(*)){@media (max-width:640px){
+ .pick-head{display:block}
+ .pick-head h2{margin:0 0 4px;font-size:1.25rem}
+ .pick-row{display:flex;gap:8px;overflow-x:auto;margin:10px -16px 0;padding:2px 16px 4px;scrollbar-width:none;overscroll-behavior-x:contain}
+ .pick-row::-webkit-scrollbar{display:none}
+ .pick-row input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
+ .pick-row label{display:inline-flex;align-items:center;flex:none;min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--line);color:var(--muted);font-size:.9rem;font-weight:700;white-space:nowrap;cursor:pointer}
+ .pick-row input:checked+label{color:var(--text);border-color:var(--acc);background:color-mix(in srgb,var(--acc) 12%,transparent)}
+ .pick-row input:focus-visible+label{outline:2px solid var(--acc);outline-offset:2px}
+ .pick-pane{display:none}
+ /* The section's own heading stays (its text comes before it otherwise); the pick block's second heading gives way. */
+ .md-pick-sec .pick-head h2{display:none}
+}}
+/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
+ replaces: a heading and its content, with no card around it. */
+.md-fold-block{margin:32px 0}
+.md-fold-block>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:48px;padding:0 16px;cursor:pointer;color:var(--text);font-weight:700;list-style:none;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
+.md-fold-block>summary::-webkit-details-marker{display:none}
+.md-fold-block>summary h2{margin:0;padding:0;border:0;font-size:1.05rem}
+.md-fold-block>summary span{color:var(--muted);font-weight:600;font-size:.86rem;text-align:right}
+.md-fold-block>summary:focus-visible{outline:2px solid var(--acc);outline-offset:-2px;border-radius:14px}
+.md-fold-block .sc-sec{margin:8px 0 0}
+.md-fold-block .sc-sec>h2{display:none}
+.md-fold-block .ov-shipped-home{margin:8px 0 0 !important;padding:0 !important}
+@media (min-width:641px){
+ .md-fold-block{border:0;background:none;margin-top:40px}
+ .md-fold-block>summary{display:block;min-height:0;padding:0;border:0;background:none}
+ .md-fold-block>summary h2{margin:0 0 6px;font-size:1.25rem;border-bottom:1px solid var(--line);padding-bottom:.2em}
+ .md-fold-block>summary span{display:none}
+}
`;
`;
const DESCRIPTION = 'Videos, clips, pictures and files for every OpenVibe site, and the same API for your apps: objects, S3, thumbnails in any size and HLS.';
const DESCRIPTION = 'Videos, clips, pictures and files for every OpenVibe site, and the same API for your apps: objects, S3, thumbnails in any size and HLS.';
@@ -286,12 +323,14 @@ function homeFaqJsonLd() {
@@ -286,12 +323,14 @@ function homeFaqJsonLd() {
}
}
const bytes = (n) => { const v = Number(n) || 0; return v >= 1024 ** 3 ? `${+(v / 1024 ** 3).toFixed(1)} GB` : `${Math.round(v / 1024 ** 2)} MB`; };
const bytes = (n) => { const v = Number(n) || 0; return v >= 1024 ** 3 ? `${+(v / 1024 ** 3).toFixed(1)} GB` : `${Math.round(v / 1024 ** 2)} MB`; };
-/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions. */
-function developerSections() {
+/** The first page's developer half: what Media is for, how an app uses it, the limits it enforces, the questions.
+ * On a phone the parts read once are folds and the code samples are chips (the owner's short-pages rule, 2026-10-30);
+ * every part stays in the page, and a wider screen shows them all. */
+function developerSections(rules) {
const L = require('../limits').limitsOf(config).limits;
const L = require('../limits').limitsOf(config).limits;
const rows = L.filter((l) => ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].includes(l.id))
const rows = L.filter((l) => ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].includes(l.id))
.map((l) => ({ label: l.label, values: [l.unit === 'bytes' ? bytes(l.production) : `${l.production} ${l.unit}`, l.unit === 'bytes' ? bytes(l.sandbox) : `${l.sandbox} ${l.unit}`] }));
.map((l) => ({ label: l.label, values: [l.unit === 'bytes' ? bytes(l.production) : `${l.production} ${l.unit}`, l.unit === 'bytes' ? bytes(l.sandbox) : `${l.sandbox} ${l.unit}`] }));
- return showcase.features({
+ const what = showcase.features({
id: 'what',
id: 'what',
title: 'What it does',
title: 'What it does',
lede: 'The media layer under every OpenVibe site, open to your apps through the same API.',
lede: 'The media layer under every OpenVibe site, open to your apps through the same API.',
@@ -303,28 +342,41 @@ function developerSections() {
@@ -303,28 +342,41 @@ function developerSections() {
{ icon: 'ov:camera', title: 'Live frames', text: '/live/@<name>/frame.jpg: a near-real-time picture of a live stream, for embeds and bots.' },
{ icon: 'ov:camera', title: 'Live frames', text: '/live/@<name>/frame.jpg: a near-real-time picture of a live stream, for embeds and bots.' },
{ icon: 'ov:check', title: 'Limits in public', text: 'Every limit Media enforces is published at /limits.json, the same numbers OpenVibe.Services and Zone show.', href: '/limits.json' },
{ icon: 'ov:check', title: 'Limits in public', text: 'Every limit Media enforces is published at /limits.json, the same numbers OpenVibe.Services and Zone show.', href: '/limits.json' },
],
],
- }) + showcase.code({
- id: 'api',
- title: 'From your app',
- lede: 'Public media needs no key. Your own objects take a project token or a Zone key.',
- samples: [
- { label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt="">
+ });
+ // The code samples are blocks of the same kind: on a phone a row of chips picks one and the chip names it.
+ const samples = [
+ { id: 'thumbnail', label: 'Thumbnail', lang: 'html', code: `<img src="${config.publicUrl}/t/<thumbnail>?w=320&fmt=webp" width="320" loading="lazy" alt="">
<img src="${config.publicUrl}/live/@goosely/frame.jpg?w=640" alt="goosely, live now">` },
<img src="${config.publicUrl}/live/@goosely/frame.jpg?w=640" alt="goosely, live now">` },
- { label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');
+ { id: 'node', label: 'Node (openvibe-sdk)', lang: 'js', code: `const { createObjectsClient } = require('openvibe-sdk/media');
// tokens: createServiceTokenClient() for your developer app; app is your project id
// tokens: createServiceTokenClient() for your developer app; app is your project id
const objects = createObjectsClient({ app: 'prj_…', tokenClient: tokens });
const objects = createObjectsClient({ app: 'prj_…', tokenClient: tokens });
const obj = await objects.upload(fs.readFileSync('q3.pdf'), { kind: 'file', mimeType: 'application/pdf', filename: 'q3.pdf' });
const obj = await objects.upload(fs.readFileSync('q3.pdf'), { kind: 'file', mimeType: 'application/pdf', filename: 'q3.pdf' });
const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },
const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },
- { label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },
- ],
- }) + showcase.limits({
+ { id: 's3', label: 'S3 (any tool)', lang: 'bash', code: `aws --endpoint-url https://s3.openvibe.media s3 cp ./site s3://assets/ --recursive # a Zone key and zone` },
+ ];
+ const panes = samples.map((c) => ({
+ id: c.id,
+ label: c.label,
+ html: `<figure><figcaption>${esc(c.label)}</figcaption><pre tabindex="0" aria-label="${esc(c.label)}"><code class="language-${esc(c.lang)}">${esc(c.code)}</code></pre></figure>`,
+ }));
+ if (rules) rules.push(pickRules('api', panes.map((p) => p.id)));
+ const api = `<section class="sc-sec md-pick-sec" id="api" aria-labelledby="api-h"><h2 id="api-h">From your app</h2>
+<p class="sc-lede">Public media needs no key. Your own objects take a project token or a Zone key.</p>
+${pickBlock({ key: 'api', label: 'The ways to call Media', head: 'From your app', bodyClass: 'sc-code', panes })}
+</section>`;
+ const limits = showcase.limits({
title: 'Limits',
title: 'Limits',
lede: 'What one project gets in each environment.',
lede: 'What one project gets in each environment.',
columns: ['Production', 'Sandbox'],
columns: ['Production', 'Sandbox'],
rows,
rows,
source: `${config.publicUrl}/limits.json`,
source: `${config.publicUrl}/limits.json`,
- }) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;
+ });
+ const questions = `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2>Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;
+ return foldBlock({ id: 'what', head: '<h2 id="what-h">What it does</h2>', html: what })
+ + api
+ + foldBlock({ id: 'limits', head: '<h2 id="limits-h">Limits</h2>', html: limits })
+ + foldBlock({ id: 'questions', head: '<h2 id="faq-h">Questions</h2>', html: questions });
}
}
// The kit card's type by media kind (its shape and its icon when there is no picture).
// The kit card's type by media kind (its shape and its icon when there is no picture).
@@ -333,6 +385,8 @@ const asCard = (c) => kit.card({ href: c.view, title: c.title || c.kind, type: C
@@ -333,6 +385,8 @@ const asCard = (c) => kit.card({ href: c.view, title: c.title || c.kind, type: C
function renderPage(tab, page, data, counts, { home = false } = {}) {
function renderPage(tab, page, data, counts, { home = false } = {}) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
+ // The rules that show the picked block of the pick block are written for this page.
+ const rules = [];
const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
// The front page is "/" itself; "/?tab=all" (and every other tab and page) is the index.
// The front page is "/" itself; "/?tab=all" (and every other tab and page) is the index.
const first = home;
const first = home;
@@ -362,15 +416,15 @@ ${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}<
@@ -362,15 +416,15 @@ ${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}<
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>`;
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>`;
const body = `${heading}
const body = `${heading}
${first ? `<section class="sc-sec sc-media">${kit.rail({ id: 'media', wrap: true, title: 'Latest public media', more: { href: '/?tab=all', label: `See all ${data.total.toLocaleString('en-US')}` }, items: data.cards.slice(0, 12).map(asCard) })}<p class="sc-kinds" aria-label="Browse by kind">${TABS.filter(([k]) => k !== 'all' && (counts[k] ?? 0) > 0).map(([k, label]) => kit.button({ href: `/?tab=${k}`, label: `${label} ${(counts[k] ?? 0).toLocaleString('en-US')}`, kind: 'quiet', small: true })).join(' ')}</p></section>` : list}
${first ? `<section class="sc-sec sc-media">${kit.rail({ id: 'media', wrap: true, title: 'Latest public media', more: { href: '/?tab=all', label: `See all ${data.total.toLocaleString('en-US')}` }, items: data.cards.slice(0, 12).map(asCard) })}<p class="sc-kinds" aria-label="Browse by kind">${TABS.filter(([k]) => k !== 'all' && (counts[k] ?? 0) > 0).map(([k, label]) => kit.button({ href: `/?tab=${k}`, label: `${label} ${(counts[k] ?? 0).toLocaleString('en-US')}`, kind: 'quiet', small: true })).join(' ')}</p></section>` : list}
-${first ? developerSections() : ''}
-${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
+${first ? developerSections(rules) : ''}
+${first ? foldBlock({ id: 'shipped', head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Media</h2>', html: frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) }) : ''}`;
return pageFrame.page({
return pageFrame.page({
seo: {
seo: {
title: TITLE, description: DESCRIPTION, canonical,
title: TITLE, description: DESCRIPTION, canonical,
robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],
jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],
},
},
- css: BROWSE_CSS,
+ css: BROWSE_CSS + (rules.length ? `@supports selector(:has(*)){@media (max-width:640px){${rules.join('')}}}` : ''),
kit: first,
kit: first,
body,
body,
// Later pages are noindex and carry no structured data, the summary's WebPage included.
// Later pages are noindex and carry no structured data, the summary's WebPage included.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
+ *
+ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
+ * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
+ * browser without :has()) shows them all and no chips.
+ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
+ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
+ * screen and remembers the reader's choice.
+ *
+ * The CSS for both lives in public/css/app.css; the rule that shows the picked block is written for the page,
+ * because there is one per block.
+ */
+const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * A pick block. The panes are the page's own blocks; the block's heading gives way to the chip that names it on a
+ * phone, and the chips are gone on a wide screen, where every pane shows at once.
+ *
+ * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', headId = '', bodyClass = '', panes }) {
+ if (!panes || !panes.length) return '';
+ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
+ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${p.html}</div>`).join('');
+ const heading = `<h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2>`;
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head">${heading}</div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body${bodyClass ? ` ${bodyClass}` : ''}">${body}</div></div>`;
+}
+
+/** The rules for every pane of a pick block, in the order they appear: one rule per pane, so the CSS is written for the page. */
+function pickRules(key, ids) {
+ return ids.map((id) => `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`).join('');
+}
+
+/**
+ * A fold. The head is the heading the block had; the html is what it holds.
+ *
+ * foldBlock({ id, head, html })
+ */
+function foldBlock({ id, head, html }) {
+ return `<details class="md-fold md-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+function foldScript(id) {
+ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
+}
+
+module.exports = { pickBlock, pickRules, foldBlock, foldScript };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// The home on a phone (the owner's short-pages rule, 2026-10-30): the code samples are one pick block, a row of chips
+// picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the chips;
+// every block stays in the page for crawlers; a wider screen shows them all and no chips.
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+
+(async () => {
+ const tmp = path.join(os.tmpdir(), `ov-media-short-${process.pid}.db`);
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ process.env.MEDIA_PGLITE_DIR = path.join(os.tmpdir(), `ov-media-short-${process.pid}-pglite`);
+
+ const db = require('../server/db/database');
+ await db.initDb();
+
+ const express = require('express');
+ const app = express();
+ app.use('/', require('../server/public/routes'));
+ const server = app.listen(0);
+ const port = server.address().port;
+ const get = (p) => new Promise((resolve, reject) => {
+ http.get({ host: '127.0.0.1', port, path: p, headers: { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' } }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ }).on('error', reject);
+ });
+
+ try {
+ const r = await get('/');
+ assert.strictEqual(r.status, 200);
+ const text = r.body;
+
+ // The code samples: one pick block, a chip per sample, the first one picked.
+ assert.ok(text.includes('<div class="pick" id="pick-api">'), 'the code samples are one pick block');
+ assert.match(text, /<div class="pick-row" role="radiogroup" aria-label="The ways to call Media">/);
+ const chips = [...text.matchAll(/<input type="radio" name="pick-api" id="pk-api-([a-z0-9-]+)" value="\1"( checked)?><label for="pk-api-\1">([^<]+)<\/label>/g)];
+ assert.deepStrictEqual(chips.map((m) => m[1]), ['thumbnail', 'node', 's3'], 'a chip per sample');
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), ['thumbnail'], 'the first sample is picked');
+ assert.strictEqual((text.match(/class="pick-pane"/g) || []).length, chips.length, 'one pane per chip');
+ // Every sample stays in the page for the crawlers.
+ for (const label of ['Thumbnail', 'Node (openvibe-sdk)', 'S3 (any tool)']) {
+ assert.ok(text.includes(`<figcaption>${label}</figcaption>`), `${label} is in the page`);
+ }
+ // The rule that shows the picked sample is written for this page.
+ assert.ok(text.includes('.pick:has(#pk-api-thumbnail:checked) #pp-api-thumbnail{display:block}'), 'the picked sample shows');
+
+ // The folds: open in the page, closed on a phone, and their content stays in the page.
+ for (const id of ['what', 'limits', 'questions', 'shipped']) {
+ assert.ok(text.includes(`<details class="md-fold md-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
+ }
+ assert.ok(text.includes('Objects and S3'), 'what it does is still in the page');
+ assert.ok(text.includes('sc-limits'), 'the limits table is still in the page');
+ assert.ok(text.includes('Do I need an account to watch?'), 'the questions are still in the page');
+ assert.ok(text.includes('ov-shipped-home'), 'what shipped is still in the page');
+
+ // The hero and the first useful thing stay where they are.
+ assert.match(text, /<h1[\s>]/, 'the hero is still there');
+ assert.ok(text.includes('Browse public media'), 'the newest media is still the first thing to reach');
+ assert.ok(text.includes('class="sc-sec sc-media"'), 'the newest media rail is still there');
+ assert.ok(text.includes('id="api"'), 'the code samples are still there, under their own anchor');
+
+ // The CSS guard: the chips are hidden and the blocks all show until 640 px and where :has() is understood.
+ const css = fs.readFileSync(path.join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');
+ assert.match(css, /\.pick-row\{display:none\}/, 'the chips are hidden by default');
+ assert.match(css, /@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{/, 'the phone rules are under :has() and 640 px');
+ assert.match(css, /\.pick-row label\{[^}]*min-height:44px/, 'a chip is a 44 px target');
+ assert.match(css, /\.pick-pane\{display:none\}/, 'the blocks are hidden on a phone');
+
+ console.log('home short: all checks passed');
+ } finally {
+ server.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* nothing to clean */ } }
+ }
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -13,7 +13,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
(async () => {
(async () => {
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 48, // 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before
+ htmlRawKB: 50, // 48.6 with the phone pick block and folds (2026-10-30; a fresh database measures a little larger); 43.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); measured 38.8 on 2026-10-22 (fresh database): the home gained its developer half (what it does, code, limits, questions and their FAQPage data); 19.8 before
htmlBrotliKB: 12, // 10.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 9.7 on 2026-10-22; 5.0 before
htmlBrotliKB: 12, // 10.8 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 9.7 on 2026-10-22; 5.0 before
jsFiles: 4, // 3
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of