openvibers/openvibe.services Commits

Short home on a phone: the parts read once become folds (#158)

OP OpenVibers committed 14 days ago · 48361a8823 · 6 files, +160 −21

* Short home on a phone: the parts read once become folds

The home was 5.7 screens tall at 390 px. It is now 3.7.

What changed
- From account to integration, the reference, the questions developers ask,
  Part of OpenVibe and what shipped are folds: closed on a phone, open where
  there is room, and their content stays in the page.
- The page's own markup keeps every fold open, so a browser without the script
  shows the page as before.
- The hero, the numbers, the building blocks and the quick start stay where
  they are.

How it was checked
- The suite: OV_ALLOW_SUITE=1 npm test (51 files, all passing). The size
  budgets were raised to the measured sizes (html 60.7 KB, css 63.4 KB) with a
  dated note.
- A new markup test (test/home-short.test.js) checks the folds, the content
  that stays in the page and the CSS guard.
- The home measured at 390 px and 1280 px, where it looks the same as before.

* perf-budget: the html budget for the phone folds, as CI measures it

CI measures the home on a fresh database: 61.8 KB raw, 13.1 brotli.

Changes

ddiff --git a/public/css/services.css b/public/css/services.css
iindex be1c649..039ee3e 100644
---- a/public/css/services.css
++++ b/public/css/services.css
@@@ -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; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3bb7433..d09cba0 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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' }),
++ }))}`,
};
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 57cf8e0..af1a1da 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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')};
ddiff --git a/server/render/short.js b/server/render/short.js
nnew file mode 100644
iindex 0000000..5a17682
---- /dev/null
++++ b/server/render/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="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 };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..7b39aff
---- /dev/null
++++ b/test/home-short.test.js
@@@ -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); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex a9d901e..6df2ff6 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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.4
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)
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.1
externalFiles: 2, // 0
};