openvibers/openvibe.services Commits
Short home on a phone: the parts read once become folds (#158)
Changes
@@@ -90,3 +90,23 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }#reference .ovk-fold { margin-top: 12px; }.page > .ov-shipped-home { margin-top: 40px; }#how .ovk-fold .sc-lede { font-size: .95rem; }++++/* The fold (the owner's short-pages rule, 2026-10-30): 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. The page's own markup keeps it open, so a++ browser without the script shows it as before. */++.svc-fold-block { margin: 28px 0; }++.svc-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-primary, #e6edf7); font-weight: 700; list-style: none; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }++.svc-fold-block > summary::-webkit-details-marker { display: none; }++.svc-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }++.svc-fold-block > summary span { color: var(--text-secondary, #96a7c2); font-weight: 600; font-size: .86rem; text-align: right; }++.svc-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }++.svc-fold-block .sc-sec { margin: 0; }++.svc-fold-block .sc-sec > h2 { display: none; }++.svc-fold-block .ov-shipped-home { margin: 8px 16px 0; }++@media (min-width: 641px) {++ .svc-fold-block { border: 0; background: none; margin-top: 40px; }++ .svc-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }++ .svc-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.3rem; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: .2em; }++ .svc-fold-block > summary span { display: none; }++ .svc-fold-block .ov-shipped-home { margin-left: 0; margin-right: 0; }++}@@@ -23,6 +23,7 @@ const express = require('express');const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd, COMMUNITY, CODES_ORIGIN } = require('./discovery');const { html, raw, render, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');++const { pickBlock, pickRules, foldBlock } = require('../render/short');const { send } = require('../render/layout');const { markdown } = require('../render/markdown');const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');@@@ -76,6 +77,8 @@ function createPageViews(ctx) {}) : '';return {index: true, cache: PUBLIC_CACHE,++ // The shipped list is a fold on the home, so the layout must not add it a second time.++ shipped: false,jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],// kit.css draws the numbers strip, the folds and the examples list; nothing on the page needs kit-web.js.styles: [showcase.STYLESHEET, 'kit.css'],@@@ -90,23 +93,44 @@ function createPageViews(ctx) {${raw(home.numbers({ docs, programmable, status: (ctx.networkStatus || networkStatus).peek() }))}${raw(home.blocks({ apis: apiIndex.length }))}${raw(home.quickStart({ sdkVersion: docs.sdkVersion }))}--${raw(showcase.steps({-- id: 'how', title: 'From account to integration',-- items: [-- { title: 'Create a project', text: 'Sandbox-only at first; staff switch on production.', href: '/projects' },-- { title: 'Add an app and its key', text: 'Its secret shows once; a playground has its grants.' },-- { title: 'Ask for capabilities', text: 'Approved inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },-- { title: 'Get a token', text: 'Client credentials, or sign people in with PKCE.', href: '/oauth' },-- { title: 'Receive events', text: 'Subscribe, then verify each delivery\'s signature.', href: '/tools/webhooks' },-- { title: 'Publish a release', text: 'Validated; rotate or revoke keys any time.', href: '/manifests/validate' },-- ],-- }).replace(/<\/section>$/, () => `${limitsFold}</section>`))}--<section class="sc-sec" id="reference" aria-labelledby="h-ref"><h2 id="h-ref">Reference</h2>++${raw(foldBlock({++ id: 'how',++ head: '<h2 id="h-how">From account to integration</h2>',++ html: showcase.steps({++ id: 'how', title: 'From account to integration',++ items: [++ { title: 'Create a project', text: 'Sandbox-only at first; staff switch on production.', href: '/projects' },++ { title: 'Add an app and its key', text: 'Its secret shows once; a playground has its grants.' },++ { title: 'Ask for capabilities', text: 'Approved inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },++ { title: 'Get a token', text: 'Client credentials, or sign people in with PKCE.', href: '/oauth' },++ { title: 'Receive events', text: 'Subscribe, then verify each delivery\'s signature.', href: '/tools/webhooks' },++ { title: 'Publish a release', text: 'Validated; rotate or revoke keys any time.', href: '/manifests/validate' },++ ],++ }).replace(/<\/section>$/, () => `${limitsFold}</section>`),++ }))}++${raw(foldBlock({++ id: 'reference',++ head: '<h2 id="h-ref">Reference</h2>',++ html: `<section class="sc-sec" id="reference" aria-labelledby="h-ref"><h2>Reference</h2><p class="sc-lede">Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/api">API explorer</a> · <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a> · <a href="/docs/limits">all limits</a>.</p><p class="sc-note">One API lists <a href="/docs/api/services">every resource of a project</a> across every service; <a href="/docs/export">export a project</a> as one archive any time. Alpha: what works and what does not is on the <a href="/policy/transparency">transparency page</a>.</p>--${raw(releasesFold)}</section>--${raw(home.faq())}--${raw(showcase.network({ service: 'services' }))}`,++${raw(releasesFold)}</section>`,++ }))}++${raw(foldBlock({++ id: 'faq',++ head: '<h2 id="h-faq">Questions developers ask</h2>',++ html: home.faq(),++ }))}++${raw(foldBlock({++ id: 'network',++ head: '<h2 id="h-network">Part of OpenVibe</h2>',++ html: showcase.network({ service: 'services' }),++ }))}++${raw(foldBlock({++ id: 'shipped',++ head: '<h2 id="h-shipped">Recently shipped on OpenVibe.Services</h2>',++ html: frame.shipped({ service: 'services', title: 'Recently shipped on OpenVibe.Services' }),++ }))}`,};}@@@ -102,14 +102,15 @@ ${appIcon.headTags({ site: 'services' })}${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="services@${esc(RELEASE)}">--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>++${o.head || ''}`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript><main id="main" class="page">${render(crumbs)}${render(o.body)}--${o.path === '/' ? frame.shipped({ service: 'services', title: `Recently shipped on ${SITE_NAME}` }) : ''}++${o.path === '/' && o.shipped !== false ? frame.shipped({ service: 'services', title: `Recently shipped on ${SITE_NAME}` }) : ''}</main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};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="svc-fold svc-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,44 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs are folds: closed on++ * a phone, open where there is room, and their content stays in the page for crawlers.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ await check('the home on a phone: the parts read once are folds', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const text = r.text;++++ // The folds: open in the page, closed on a phone, and their content stays in the page.++ for (const id of ['how', 'reference', 'faq', 'network', 'shipped']) {++ assert.ok(text.includes(`<details class="svc-fold svc-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`);++ }++ // The content of every fold is still in the page.++ assert.ok(text.includes('Create a project'), 'the path from account to integration is still in the page');++ assert.ok(text.includes('API explorer'), 'the reference is still in the page');++ assert.ok(text.includes('What does it cost?'), 'the questions are still in the page');++ assert.ok(text.includes('Part of OpenVibe'), 'the other OpenVibe places 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.match(text, /Create a project<\/a>/, 'the main action is still there');++ assert.match(text, /id="blocks"/, 'the building blocks are still there');++ assert.match(text, /id="quick-start"/, 'the quick start is still there');++++ // The CSS guard: the fold is a section heading where there is room and a closed card on a phone.++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'services.css'), 'utf8');++ assert.match(css, /\.svc-fold-block > summary \{[^}]*min-height: 48px/, 'the fold is a 48 px target');++ assert.match(css, /\.svc-fold-block \.sc-sec > h2 \{ display: none; \}/, 'the fold\'s heading replaces the section\'s own');++ assert.match(css, /@media \(min-width: 641px\) \{/, 'where there is room the fold looks like the section it replaces');++ });++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 60, // 57.4 with the home as a taste and a door (2026-10-29: the building blocks as a launcher with 15 inline ring icons; the text-only service catalog, what-you-can-build and integration cards it replaced measured 54.5 on Shared 4.38.0); 50.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 46.0 with the twelve text-only building blocks (plan track K, 2026-10-24); 42.9 with the home's own sections (2026-10-22: the platform in numbers, nine text-only "what you can build" cards, three quick-start samples, six FAQs and their FAQPage JSON-LD); 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)-- htmlBrotliKB: 13, // 11.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 10.0 with those sections (2026-10-22); 7.4++ htmlRawKB: 63, // 61.8 with the phone folds (2026-10-30; 60.7 on a local database); 57.4 with the home as a taste and a door (2026-10-29: the building blocks as a launcher with 15 inline ring icons; the text-only service catalog, what-you-can-build and integration cards it replaced measured 54.5 on Shared 4.38.0); 50.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 46.0 with the twelve text-only building blocks (plan track K, 2026-10-24); 42.9 with the home's own sections (2026-10-22: the platform in numbers, nine text-only "what you can build" cards, three quick-start samples, six FAQs and their FAQPage JSON-LD); 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)++ htmlBrotliKB: 14, // 13.1 with the phone folds (2026-10-30); 11.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 10.0 with those sections (2026-10-22); 7.4jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 3, // 3 with kit.css (2026-10-29: the home's numbers strip, folds and examples list); 2; 1-- cssRawKB: 63, // 61.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 58.2 with kit.css (25.6 KB raw; 34.0 before on Shared 4.38.0): the numbers as one kit.stats strip, what is read once in kit.fold and the examples as kit rows, which took the phone home from 16.1 screens to 5.8; 31.8 openvibe-shared 4.26.1 (hero visual); 26.3 with Shared 4.13.0 (2026-10-26: account links); 20.3 with the numbers and FAQ styles (2026-10-22); 17.3 (services.css + the cached /shared/showcase.css)-- cssBrotliKB: 13, // 12.1 with kit.css (2026-10-29; 7.3 before); 5.8 with Shared 4.13.0 (2026-10-26: account links); 4.1++ cssRawKB: 64, // 63.4 with the phone fold rules (2026-10-30); 61.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 58.2 with kit.css (25.6 KB raw; 34.0 before on Shared 4.38.0): the numbers as one kit.stats strip, what is read once in kit.fold and the examples as kit rows, which took the phone home from 16.1 screens to 5.8; 31.8 openvibe-shared 4.26.1 (hero visual); 26.3 with Shared 4.13.0 (2026-10-26: account links); 20.3 with the numbers and FAQ styles (2026-10-22); 17.3 (services.css + the cached /shared/showcase.css)++ cssBrotliKB: 14, // 13.2 with the phone fold rules (2026-10-30); 12.1 with kit.css (2026-10-29; 7.3 before); 5.8 with Shared 4.13.0 (2026-10-26: account links); 4.1externalFiles: 2, // 0};
@@ -90,3 +90,23 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
@@ -90,3 +90,23 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
#reference .ovk-fold { margin-top: 12px; }
#reference .ovk-fold { margin-top: 12px; }
.page > .ov-shipped-home { margin-top: 40px; }
.page > .ov-shipped-home { margin-top: 40px; }
#how .ovk-fold .sc-lede { font-size: .95rem; }
#how .ovk-fold .sc-lede { font-size: .95rem; }
+
+/* The fold (the owner's short-pages rule, 2026-10-30): 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. The page's own markup keeps it open, so a
+ browser without the script shows it as before. */
+.svc-fold-block { margin: 28px 0; }
+.svc-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-primary, #e6edf7); font-weight: 700; list-style: none; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
+.svc-fold-block > summary::-webkit-details-marker { display: none; }
+.svc-fold-block > summary h2 { margin: 0; padding: 0; border: 0; font-size: 1.05rem; }
+.svc-fold-block > summary span { color: var(--text-secondary, #96a7c2); font-weight: 600; font-size: .86rem; text-align: right; }
+.svc-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
+.svc-fold-block .sc-sec { margin: 0; }
+.svc-fold-block .sc-sec > h2 { display: none; }
+.svc-fold-block .ov-shipped-home { margin: 8px 16px 0; }
+@media (min-width: 641px) {
+ .svc-fold-block { border: 0; background: none; margin-top: 40px; }
+ .svc-fold-block > summary { display: block; min-height: 0; padding: 0; border: 0; background: none; }
+ .svc-fold-block > summary h2 { margin: 0 0 6px; font-size: 1.3rem; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: .2em; }
+ .svc-fold-block > summary span { display: none; }
+ .svc-fold-block .ov-shipped-home { margin-left: 0; margin-right: 0; }
+}
@@ -23,6 +23,7 @@ const express = require('express');
@@ -23,6 +23,7 @@ const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, COMMUNITY, CODES_ORIGIN } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd, COMMUNITY, CODES_ORIGIN } = require('./discovery');
const { html, raw, render, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
const { html, raw, render, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
+const { pickBlock, pickRules, foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
@@ -76,6 +77,8 @@ function createPageViews(ctx) {
@@ -76,6 +77,8 @@ function createPageViews(ctx) {
}) : '';
}) : '';
return {
return {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
+ // The shipped list is a fold on the home, so the layout must not add it a second time.
+ shipped: false,
jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],
jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],
// kit.css draws the numbers strip, the folds and the examples list; nothing on the page needs kit-web.js.
// kit.css draws the numbers strip, the folds and the examples list; nothing on the page needs kit-web.js.
styles: [showcase.STYLESHEET, 'kit.css'],
styles: [showcase.STYLESHEET, 'kit.css'],
@@ -90,23 +93,44 @@ function createPageViews(ctx) {
@@ -90,23 +93,44 @@ function createPageViews(ctx) {
${raw(home.numbers({ docs, programmable, status: (ctx.networkStatus || networkStatus).peek() }))}
${raw(home.numbers({ docs, programmable, status: (ctx.networkStatus || networkStatus).peek() }))}
${raw(home.blocks({ apis: apiIndex.length }))}
${raw(home.blocks({ apis: apiIndex.length }))}
${raw(home.quickStart({ sdkVersion: docs.sdkVersion }))}
${raw(home.quickStart({ sdkVersion: docs.sdkVersion }))}
-${raw(showcase.steps({
- id: 'how', title: 'From account to integration',
- items: [
- { title: 'Create a project', text: 'Sandbox-only at first; staff switch on production.', href: '/projects' },
- { title: 'Add an app and its key', text: 'Its secret shows once; a playground has its grants.' },
- { title: 'Ask for capabilities', text: 'Approved inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },
- { title: 'Get a token', text: 'Client credentials, or sign people in with PKCE.', href: '/oauth' },
- { title: 'Receive events', text: 'Subscribe, then verify each delivery\'s signature.', href: '/tools/webhooks' },
- { title: 'Publish a release', text: 'Validated; rotate or revoke keys any time.', href: '/manifests/validate' },
- ],
- }).replace(/<\/section>$/, () => `${limitsFold}</section>`))}
-<section class="sc-sec" id="reference" aria-labelledby="h-ref"><h2 id="h-ref">Reference</h2>
+${raw(foldBlock({
+ id: 'how',
+ head: '<h2 id="h-how">From account to integration</h2>',
+ html: showcase.steps({
+ id: 'how', title: 'From account to integration',
+ items: [
+ { title: 'Create a project', text: 'Sandbox-only at first; staff switch on production.', href: '/projects' },
+ { title: 'Add an app and its key', text: 'Its secret shows once; a playground has its grants.' },
+ { title: 'Ask for capabilities', text: 'Approved inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },
+ { title: 'Get a token', text: 'Client credentials, or sign people in with PKCE.', href: '/oauth' },
+ { title: 'Receive events', text: 'Subscribe, then verify each delivery\'s signature.', href: '/tools/webhooks' },
+ { title: 'Publish a release', text: 'Validated; rotate or revoke keys any time.', href: '/manifests/validate' },
+ ],
+ }).replace(/<\/section>$/, () => `${limitsFold}</section>`),
+ }))}
+${raw(foldBlock({
+ id: 'reference',
+ head: '<h2 id="h-ref">Reference</h2>',
+ html: `<section class="sc-sec" id="reference" aria-labelledby="h-ref"><h2>Reference</h2>
<p class="sc-lede">Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/api">API explorer</a> · <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a> · <a href="/docs/limits">all limits</a>.</p>
<p class="sc-lede">Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/api">API explorer</a> · <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a> · <a href="/docs/limits">all limits</a>.</p>
<p class="sc-note">One API lists <a href="/docs/api/services">every resource of a project</a> across every service; <a href="/docs/export">export a project</a> as one archive any time. Alpha: what works and what does not is on the <a href="/policy/transparency">transparency page</a>.</p>
<p class="sc-note">One API lists <a href="/docs/api/services">every resource of a project</a> across every service; <a href="/docs/export">export a project</a> as one archive any time. Alpha: what works and what does not is on the <a href="/policy/transparency">transparency page</a>.</p>
-${raw(releasesFold)}</section>
-${raw(home.faq())}
-${raw(showcase.network({ service: 'services' }))}`,
+${raw(releasesFold)}</section>`,
+ }))}
+${raw(foldBlock({
+ id: 'faq',
+ head: '<h2 id="h-faq">Questions developers ask</h2>',
+ html: home.faq(),
+ }))}
+${raw(foldBlock({
+ id: 'network',
+ head: '<h2 id="h-network">Part of OpenVibe</h2>',
+ html: showcase.network({ service: 'services' }),
+ }))}
+${raw(foldBlock({
+ id: 'shipped',
+ head: '<h2 id="h-shipped">Recently shipped on OpenVibe.Services</h2>',
+ html: frame.shipped({ service: 'services', title: 'Recently shipped on OpenVibe.Services' }),
+ }))}`,
};
};
}
}
@@ -102,14 +102,15 @@ ${appIcon.headTags({ site: 'services' })}
@@ -102,14 +102,15 @@ ${appIcon.headTags({ site: 'services' })}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="services@${esc(RELEASE)}">
<meta name="ov-boost" content="services@${esc(RELEASE)}">
-<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
+${o.head || ''}`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<main id="main" class="page">
<main id="main" class="page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${render(o.body)}
-${o.path === '/' ? frame.shipped({ service: 'services', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+${o.path === '/' && o.shipped !== false ? frame.shipped({ service: 'services', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
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="svc-fold svc-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,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs are folds: closed on
+ * a phone, open where there is room, and their content stays in the page for crawlers.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ await check('the home on a phone: the parts read once are folds', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const text = r.text;
+
+ // The folds: open in the page, closed on a phone, and their content stays in the page.
+ for (const id of ['how', 'reference', 'faq', 'network', 'shipped']) {
+ assert.ok(text.includes(`<details class="svc-fold svc-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`);
+ }
+ // The content of every fold is still in the page.
+ assert.ok(text.includes('Create a project'), 'the path from account to integration is still in the page');
+ assert.ok(text.includes('API explorer'), 'the reference is still in the page');
+ assert.ok(text.includes('What does it cost?'), 'the questions are still in the page');
+ assert.ok(text.includes('Part of OpenVibe'), 'the other OpenVibe places 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.match(text, /Create a project<\/a>/, 'the main action is still there');
+ assert.match(text, /id="blocks"/, 'the building blocks are still there');
+ assert.match(text, /id="quick-start"/, 'the quick start is still there');
+
+ // The CSS guard: the fold is a section heading where there is room and a closed card on a phone.
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'services.css'), 'utf8');
+ assert.match(css, /\.svc-fold-block > summary \{[^}]*min-height: 48px/, 'the fold is a 48 px target');
+ assert.match(css, /\.svc-fold-block \.sc-sec > h2 \{ display: none; \}/, 'the fold\'s heading replaces the section\'s own');
+ assert.match(css, /@media \(min-width: 641px\) \{/, 'where there is room the fold looks like the section it replaces');
+ });
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 60, // 57.4 with the home as a taste and a door (2026-10-29: the building blocks as a launcher with 15 inline ring icons; the text-only service catalog, what-you-can-build and integration cards it replaced measured 54.5 on Shared 4.38.0); 50.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 46.0 with the twelve text-only building blocks (plan track K, 2026-10-24); 42.9 with the home's own sections (2026-10-22: the platform in numbers, nine text-only "what you can build" cards, three quick-start samples, six FAQs and their FAQPage JSON-LD); 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)
- htmlBrotliKB: 13, // 11.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 10.0 with those sections (2026-10-22); 7.4
+ htmlRawKB: 63, // 61.8 with the phone folds (2026-10-30; 60.7 on a local database); 57.4 with the home as a taste and a door (2026-10-29: the building blocks as a launcher with 15 inline ring icons; the text-only service catalog, what-you-can-build and integration cards it replaced measured 54.5 on Shared 4.38.0); 50.7 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 46.0 with the twelve text-only building blocks (plan track K, 2026-10-24); 42.9 with the home's own sections (2026-10-22: the platform in numbers, nine text-only "what you can build" cards, three quick-start samples, six FAQs and their FAQPage JSON-LD); 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)
+ htmlBrotliKB: 14, // 13.1 with the phone folds (2026-10-30); 11.5 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 10.0 with those sections (2026-10-22); 7.4
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // 3 with kit.css (2026-10-29: the home's numbers strip, folds and examples list); 2; 1
cssFiles: 3, // 3 with kit.css (2026-10-29: the home's numbers strip, folds and examples list); 2; 1
- cssRawKB: 63, // 61.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 58.2 with kit.css (25.6 KB raw; 34.0 before on Shared 4.38.0): the numbers as one kit.stats strip, what is read once in kit.fold and the examples as kit rows, which took the phone home from 16.1 screens to 5.8; 31.8 openvibe-shared 4.26.1 (hero visual); 26.3 with Shared 4.13.0 (2026-10-26: account links); 20.3 with the numbers and FAQ styles (2026-10-22); 17.3 (services.css + the cached /shared/showcase.css)
- cssBrotliKB: 13, // 12.1 with kit.css (2026-10-29; 7.3 before); 5.8 with Shared 4.13.0 (2026-10-26: account links); 4.1
+ cssRawKB: 64, // 63.4 with the phone fold rules (2026-10-30); 61.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 58.2 with kit.css (25.6 KB raw; 34.0 before on Shared 4.38.0): the numbers as one kit.stats strip, what is read once in kit.fold and the examples as kit rows, which took the phone home from 16.1 screens to 5.8; 31.8 openvibe-shared 4.26.1 (hero visual); 26.3 with Shared 4.13.0 (2026-10-26: account links); 20.3 with the numbers and FAQ styles (2026-10-22); 17.3 (services.css + the cached /shared/showcase.css)
+ cssBrotliKB: 14, // 13.2 with the phone fold rules (2026-10-30); 12.1 with kit.css (2026-10-29; 7.3 before); 5.8 with Shared 4.13.0 (2026-10-26: account links); 4.1
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};