openvibers/openvibe.actor Commits
Actor: the parts a reader rarely needs fold on a phone (#207)
Changes
@@@ -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; }++}@@@ -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>`,};}@@@ -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')};nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/** 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 };nnew file mode 100644@@@ -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); });@@@ -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.8jsBrotliKB: 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.5cssFiles: 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};
@@ -41,3 +41,34 @@
@@ -41,3 +41,34 @@
.sc-code { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
.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: 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; } }
@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; }
+}
@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { html, raw, table, notice, time, badge } = require('../render/html');
+const { 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 store = require('../tasks/store');
const store = require('../tasks/store');
@@ -486,10 +487,17 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
@@ -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>
<section class="sc-sec hm-task" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
${box}
${box}
${raw(starts)}</section>
${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>
<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({
${fold({ id: 'route', title: 'How a task finds its agent', meta: '5 steps', body: showcase.steps({
items: [
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.' },
{ 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
@@ -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}" },
{ 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>`) })}
}) + 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>`,
<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>`,
};
};
}
}
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
@@ -17,6 +17,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
const { html, raw, esc } = require('./html');
+const { foldBlock } = require('./short');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Actor';
const SITE_NAME = 'OpenVibe.Actor';
@@ -111,7 +112,12 @@ ${render(scripts)}
@@ -111,7 +112,12 @@ ${render(scripts)}
<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: '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>
</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,60 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/** 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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); });
@@ -21,8 +21,8 @@ const BUDGETS = {
@@ -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
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
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)
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
externalFiles: 0, // 0
};
};