openvibers/openvibe.media Commits

The home on a phone: the code samples are one pick block, the rest are folds (#235)

OP OpenVibers committed 14 days ago · 53574bc724 · 4 files, +203 −18

* The home on a phone: the code samples are one pick block, the rest are folds

* The section's own heading stays on a phone: the text under it comes first

Changes

ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 716e1f3..f2e94a2 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 id
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 { 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: C
function 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.
ddiff --git a/server/public/short.js b/server/public/short.js
nnew file mode 100644
iindex 0000000..2758a8a
---- /dev/null
++++ b/server/public/short.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/**
++ * 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 };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..e61227b
---- /dev/null
++++ b/test/home-short.test.js
@@@ -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); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 3a21141..d44bc00 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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 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
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of