openvibers/openvibe.actor Commits

Actor: the parts a reader rarely needs fold on a phone (#207)

OP OpenVibers committed 14 days ago · 9df15f3a44 · 6 files, +165 −8

* Actor: the parts a reader rarely needs fold on a phone

The home was 4.9 screens at 390 px. The agents, how it works, the questions
and what shipped are folds now: closed on a phone, where the title row opens
them, and open where there is room, where they look like the sections they
replace. A checkbox and a label, no script, and every part stays in the page
for the crawlers. The task box and the tasks to start from stay first. 2.9
screens at 390 px.

The CSS budget moves to the measured size (92.5 KB raw, 20.5 KB brotli).

* Actor: the fold rules only hide a part's own heading on a phone

Where there is room the fold is open and the part keeps the heading it had;
only on a phone does the title row name it instead.

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
iindex 521af98..7c317cf 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -41,3 +41,34 @@
.sc-code { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
@media (max-width: 520px) { .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } }
@media (max-width: 420px) { .sc-hero, .sc-sec, .sc-cta { padding-left: 16px; padding-right: 16px; } }
++
++/* The fold (the owner's short-pages rule, 2026-10-30): a part a reader rarely needs. On a phone it is closed — the
++ title row is a 44 px target that opens it — and where there is room it is open and looks like the section it
++ replaces. A checkbox and a label, no script: the part stays in the page for the crawlers. */
++.fold { margin-top: 26px; }
++.fold-toggle { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
++.fold-h { display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer; }
++.fold-h > h2 { margin: 0; font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.02em; flex: 1 1 auto; }
++.fold-h:focus-within { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.fold-mark { flex: none; }
++.fold-mark::after { content: "+"; font-size: 20px; font-weight: 400; color: var(--text-muted, #7386a3); }
++.fold-toggle:checked ~ .fold-h .fold-mark::after { content: "\2212"; }
++.fold-body { display: none; }
++.fold-toggle:checked ~ .fold-body { display: block; }
++.fold-body > .sc-sec { margin-top: 16px; }
++/* The title row names the part, so a part that keeps its own heading gives way to it on a phone (and keeps it where
++ there is room). */
++@media (max-width: 640px) {
++ .fold-bare .fold-body > section > h2, .fold-bare .fold-body > .sc-sec > h2, .fold-bare .fold-body .ov-shipped-home h3 { display: none; }
++}
++.fold:not(.fold-bare) .fold-body .ov-shipped-home h3 { display: none; }
++@media (min-width: 641px) {
++ .fold { margin-top: 56px; }
++ .fold-toggle { display: none; }
++ .fold-h { min-height: 0; margin-bottom: 10px; cursor: default; }
++ .fold-mark { display: none; }
++ .fold-body { display: block; }
++ .fold-bare > .fold-h { display: none; }
++ .fold-bare { margin-top: 0; }
++ .fold:not(.fold-bare) .fold-body > .sc-sec { margin-top: 0; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5e347ef..88ea580 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
++const { foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const store = require('../tasks/store');
@@@ -486,10 +487,17 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
<section class="sc-sec hm-task" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
${box}
${raw(starts)}</section>
--<section class="sc-sec" id="agents" aria-labelledby="h-agents"><h2 id="h-agents">The agents</h2>
++${raw(foldBlock({
++ id: 'agents',
++ head: '<h2 id="h-agents">The agents</h2>',
++ html: String(html`<section class="sc-sec" id="agents" aria-labelledby="h-agents">
<p class="sc-lede">Every agent Actor can route to, what it charges and whether it is working right now. <a href="/agents">What each one is, its modes and details</a>.</p>
--${raw(kit.list(agents.map((a) => kit.row({ href: `/agents#${a.id}`, title: a.name, badge: a.available ? kit.badge('working', 'ok') : kit.badge('not here yet'), sub: `${priceText(a.rate_card)}${a.model ? ` · ${a.model}` : ''}` }))))}</section>
--<section class="sc-sec hm-more" aria-labelledby="h-how"><h2 id="h-how">How it works</h2>
++${raw(kit.list(agents.map((a) => kit.row({ href: `/agents#${a.id}`, title: a.name, badge: a.available ? kit.badge('working', 'ok') : kit.badge('not here yet'), sub: `${priceText(a.rate_card)}${a.model ? ` · ${a.model}` : ''}` }))))}</section>`),
++ }))}
++${raw(foldBlock({
++ id: 'how',
++ head: '<h2 id="h-how">How it works</h2>',
++ html: String(html`<section class="sc-sec hm-more" aria-labelledby="h-how">
${fold({ id: 'route', title: 'How a task finds its agent', meta: '5 steps', body: showcase.steps({
items: [
{ title: 'Sort the task', text: 'An answer, a lookup, research, a live web search, code, your own robot or your own computer: that decides what an agent must be able to do.' },
@@@ -522,8 +530,14 @@ ${fold({ id: 'api', title: 'Hand it tasks from your own code', meta: 'curl, Java
{ label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createActorClient } from 'openvibe-sdk/actor';\n\nconst actor = createActorClient(\n createClient({ credentials: 'include' }));\nconst task = await actor.tasks.create({\n task: 'Summarize the latest Node.js release',\n});\nfor await (const e of actor.tasks.events(task.id)) {\n console.log(e.event, e.data);\n}" },
],
}) + String(html`<p class="hm-api-more"><a class="sc-btn sc-primary" href="/docs">Read the API</a></p>`) })}
--</section>
--${raw(showcase.faq({ id: 'faq', items: faq }))}
++</section>`),
++ }))}
++${raw(foldBlock({
++ id: 'faq',
++ bare: true,
++ head: '<h2>Questions</h2>',
++ html: showcase.faq({ id: 'faq', items: faq }),
++ }))}
<div class="hm-net">${raw(showcase.apps({ id: 'network', title: 'Part of OpenVibe', lede: 'One account across every OpenVibe site. Related places to go next:', items: related }))}</div>`,
};
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c76e24a..bf10512 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
++const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Actor';
@@@ -111,7 +112,12 @@ ${render(scripts)}
<main id="main" class="page">
${render(crumbs)}
${render(o.body)}
--${o.path === '/' ? frame.shipped({ service: 'actor', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.path === '/' ? foldBlock({
++ id: 'shipped',
++ bare: true,
++ head: `<h2>Recently shipped on ${SITE_NAME}</h2>`,
++ html: frame.shipped({ service: 'actor', 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..ec74c00
---- /dev/null
++++ b/server/render/short.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * foldBlock() a part a reader rarely needs: closed on a phone, where the title row opens it, and open where there
++ * is room, where it looks like the section it replaces. A checkbox and a label, no script: the part
++ * stays in the page for the crawlers, and a browser without CSS shows it as it was.
++ * 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, and a wider screen (or a browser without
++ * :has()) shows them all and no chips.
++ *
++ * The CSS for both lives in the site's own stylesheet; 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 piece of markup as a string: a plain string, or the raw html a template returned. */
++const str = (v) => (v && typeof v === 'object' && typeof v.s === 'string') ? v.s : String(v == null ? '' : v);
++
++/**
++ * A fold. The head is the heading the block had (an <h2>, so it stays a heading on a phone too); the html is what it
++ * holds. `bare` is for a part that keeps its own heading in the body: where there is room the title row gives way to
++ * it, so the part looks exactly as it did.
++ *
++ * foldBlock({ id, head, html, bare })
++ */
++function foldBlock({ id, head, html, bare = false }) {
++ const key = `fold-${esc(id)}`;
++ return `<div class="fold${bare ? ' fold-bare' : ''}" id="${key}">`
++ + `<input class="fold-toggle" type="checkbox" id="${key}-open">`
++ + `<label class="fold-h" for="${key}-open">${str(head)}<span class="fold-mark" aria-hidden="true"></span></label>`
++ + `<div class="fold-body">${str(html)}</div></div>`;
++}
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes, checked }) panes: [{ id, label, html }]; checked: the pane the first
++ * chip picks (the first one by default)
++ */
++function pickBlock({ key, label, head = '', panes, checked = '' }) {
++ if (!panes.length) return '';
++ const first = panes.find((p) => p.id === checked) ? checked : panes[0].id;
++ const chips = panes.map((p) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${p.id === first ? ' 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)}">${str(p.html)}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++module.exports = { foldBlock, pickBlock, pickRule, pickRules };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
nnew file mode 100644
iindex 0000000..9942453
---- /dev/null
++++ b/test/home-short.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++// The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — the agents, how
++// it works, the questions and what shipped — fold closed, with a checkbox and a label and no script. Everything stays
++// in the page for the crawlers, and where there is room the fold is open and looks like the section it replaces.
++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 folds the agents, how it works, the questions and what shipped, and keeps them in the page', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const home = r.text;
++ for (const [id, title] of [['agents', 'The agents'], ['how', 'How it works'], ['faq', 'Questions'], ['shipped', 'Recently shipped on OpenVibe.Actor']]) {
++ const re = new RegExp(`<div class="fold[^"]*" id="fold-${id}"><input class="fold-toggle" type="checkbox" id="fold-${id}-open">`
++ + `<label class="fold-h" for="fold-${id}-open"><h2[^>]*>[^<]*${title}[^<]*</h2>.*?<div class="fold-body">`);
++ assert.ok(re.test(home), `the ${title} fold is on the home`);
++ }
++ // The parts themselves are in the page, not removed: the questions, the agents and what shipped.
++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(home) || [''])[0];
++ assert.ok((faq.match(/<summary>[^<]+<\/summary>/g) || []).length >= 6, 'the questions are in the page');
++ assert.match(home, /id="h-agents"/, 'the agents keep their heading');
++ assert.match(home, /data-ov-shipped="list"/, 'what shipped is in the page');
++ // What the home keeps: the task box and the tasks to start from come first.
++ assert.ok(home.indexOf('id="task"') < home.indexOf('id="fold-agents"'), 'the task box stays first');
++ assert.match(home, /id="templates"/, 'the tasks to start from stay on the home');
++ });
++
++ await check('the fold is closed on a phone and open where there is room', async () => {
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'home.css'), 'utf8');
++ const fold = css.slice(css.indexOf('/* The fold (the owner'));
++ assert.ok(fold.length > 0, 'the fold rules are in home.css');
++ assert.match(fold, /\.fold-body \{ display: none; \}/, 'the body is closed by default');
++ assert.match(fold, /\.fold-toggle:checked ~ \.fold-body \{ display: block; \}/, 'the title row opens it');
++ assert.match(fold, /\.fold-toggle \{ display: none; \}/, 'the toggle is hidden where there is room');
++ assert.match(fold, /\.fold-body \{ display: block; \}/, 'the body is open where there is room');
++ assert.match(fold, /\.fold-bare > \.fold-h \{ display: none; \}/, 'a part with its own heading looks as it did');
++ assert.match(fold, /\.fold-h > h2 \{[^}]*min-height:44px|min-height: 44px/, 'the title row is a 44 px target');
++ });
++
++ 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 dde0cd8..5a35750 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,8 +21,8 @@ const BUDGETS = {
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; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
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; 56.5
cssFiles: 5, // 5 with the shared kit.css and room.css (2026-10-29: the home as a taste and a door, 17 → 5.1 phone screens); 2 (actor.css + the cached /shared/showcase.css); 3 with home.css, the home's own sections (10-22)
-- cssRawKB: 92, // 83 the home's numbers, rows and folds (kit.css, 25.6 KB raw) and its rail of task cards (room.css, 15.7 KB raw), both cached /shared/ files: the page went from 17 phone screens to 5.1 (2026-10-29); 41.8 openvibe-shared 4.26.1 (hero visual); 35.1 with Shared 4.13.0 (2026-10-26: account links); 24.2 (the task page's outcome card and timeline); 28.9 with home.css
-- cssBrotliKB: 20, // 18.2 with kit.css and room.css (the short home, 2026-10-29); 7.7 with Shared 4.13.0 (2026-10-26: account links); 5.4; 6.6 with home.css // raised 9 -> 10 for the openvibe-shared Kit 2 pin (kit.css grew)
++ cssRawKB: 93, // 92.5 with the home's folds (the parts a reader rarely needs fold on a phone, 2026-10-30); 83 the home's numbers, rows and folds (kit.css, 25.6 KB raw) and its rail of task cards (room.css, 15.7 KB raw), both cached /shared/ files: the page went from 17 phone screens to 5.1 (2026-10-29); 41.8 openvibe-shared 4.26.1 (hero visual); 35.1 with Shared 4.13.0 (2026-10-26: account links); 24.2 (the task page's outcome card and timeline); 28.9 with home.css
++ cssBrotliKB: 21, // 20.5 with the home's folds (the parts a reader rarely needs fold on a phone, 2026-10-30); 18.2 with kit.css and room.css (the short home, 2026-10-29); 7.7 with Shared 4.13.0 (2026-10-26: account links); 5.4; 6.6 with home.css // raised 9 -> 10 for the openvibe-shared Kit 2 pin (kit.css grew)
externalFiles: 0, // 0
};