openvibers/openvibe.actor Commits

Actor's front page: a taste and a door, not the manual (17 phone screens → 5) (#178)

OP OpenVibers committed 15 days ago · 39743f0183 · 9 files, +184 −161

The home stacked eleven task templates in eight boxes, a 14-row status table, seven
feature cards that repeated the templates, 34 tool chips, four long agent cards, the
questions as open text and a closing call to action that repeated the API section.

Now, from the shared kit: one kit.stats strip; the task box with the templates as a
kit.rail of kit.cards (a tap fills the box, which now says what a robot, computer,
desk or private task needs); the agents as kit.list rows that open /agents#<id> (their
blurbs moved to /agents); how routing works, the comparison, what works today, the
tools and the API as closed kit.folds whose anchors open them; showcase.faq; and
Part of OpenVibe as a showcase.apps launcher. Signed out with nothing chosen, the
hero's Sign in is the door (no second sign-in box).

openvibe-shared v4.36.2 -> v4.38.0 (showcase.apps). The home loads kit.css and room.css:
cssFiles 3 -> 5, cssRawKB 46 -> 92 (83 measured), cssBrotliKB 10 -> 20 (18.2), stated in
the budget notes; HTML 64.3 -> 60.7 KB, JS unchanged. 390 px: 17.0 -> 5.1 screens.

Changes

ddiff --git a/README.md b/README.md
iindex 77b2f74..c4a2fc0 100644
---- a/README.md
++++ b/README.md
@@@ -95,7 +95,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.
- Request telemetry (`migrations/0010_telemetry.sql`): each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `server/index.js` flushes it every minute and on stop, and runs the 14-day retention.
- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).
--- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second), task templates that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in), the Tools tools the runtime can run, the agents, the API samples and the questions people ask first, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).
++- The home page (`server/http/pages.js`, `public/css/home.css`, `test/home.test.js`) is a taste and a door, about five phone screens, built from the shared kit (`kit.css`, `room.css`, `showcase.css`): the task box, numbers read from the catalog, the running config and OpenVibe.Tools' catalog (the runtime's own cached copy, waited for at most a second) as one `kit.stats` strip, task templates as a rail of cards that fill the task box (`/?task=…&mode=…`, kept by no shared cache; signed out, the task waits for sign-in; what a robot, computer, desk or private task needs is said in the box), the agents as rows that open `/agents`, the parts read once as folds (how a task finds its agent, the comparison, what works today, the Tools tools the runtime can run, the API samples; a link to `#tools` or `#api` opens its fold), the questions people ask first and the rest of OpenVibe as a launcher, with JSON-LD WebSite, WebApplication, WebPage, ItemList (the agents) and FAQPage (exactly the questions shown).
## Does not own
@@@ -254,5 +254,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.229.0
- openvibe-sdk: v0.67.0
--- openvibe-shared: v4.37.0
++- openvibe-shared: v4.38.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c5610cb..4549a35 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.actor",
"contracts": "openvibe-contracts v0.229.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.37.0",
++ "shared": "openvibe-shared v4.38.0",
"packages": [
"openvibe-contracts v0.229.0",
"openvibe-sdk v0.67.0",
-- "openvibe-shared v4.37.0"
++ "openvibe-shared v4.38.0"
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 701231a..c1e85b8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1190,9 +1190,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.37.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
-- "integrity": "sha512-zT73fINooICUxuoxT8bwS1C5bh6ieNaDAX6aUaMdzMth1nTXbkwh54bgiPMytAJ2D0a8b3pqxhLYCvxFVzCVqA==",
++ "version": "4.38.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
++ "integrity": "sha512-4sEQ78EQaw+cWWJs+AlPRhxObzHT1nwTMz6yHVBL20Mr+raAhhP10DtL1OTnbuMCDVsYhVwpd3QGgmyC6391EA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 854ecae..1475363 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.37.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.38.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/actor.css b/public/css/actor.css
iindex a43d838..f21d47c 100644
---- a/public/css/actor.css
++++ b/public/css/actor.css
@@@ -39,7 +39,6 @@ form.inline { display: inline; }
/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
--.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
button.sc-btn { background: transparent; }
button.sc-btn.sc-primary { border: 0; }
@@@ -75,14 +74,8 @@ button.sc-btn.sc-primary { border: 0; }
.examples { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
.examples li { padding: 8px 12px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .94rem; }
--/* ── The agents ── */
--.agent-cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
--.agent-card { padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); }
--/* An agent that is off: a dashed outline and no fill, never faded text (opacity took it below WCAG AA contrast). */
--.agent-card.off { border-style: dashed; background: transparent; }
--.agent-card .muted { color: var(--text-secondary, #96a7c2); }
--.agent-card header { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
--.agent-card p { margin: .5rem 0 0; font-size: 14px; line-height: 1.5; }
++/* ── The agents (/agents): what each one is, under its name ── */
++.agent-blurb { min-width: 16rem; margin: .4rem 0 0; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
/* ── One task: the status line and the task, the outcome, how it went, why ── */
.task-head { margin-top: .4rem; }
ddiff --git a/public/css/home.css b/public/css/home.css
iindex c6643cf..907d6f0 100644
---- a/public/css/home.css
++++ b/public/css/home.css
@@@ -1,42 +1,45 @@
--/* openvibe.actor's home page only (server/http/pages.js): the numbers, the task templates, the tools Actor can run and
-- the questions. Colours come from the Frame's theme tokens, so it follows the person's theme, light or dark. */
++/* openvibe.actor's home page only (server/http/pages.js). The pieces are the shared kit's (kit.css: the numbers, rows and
++ folds; room.css: the cards and the rail; showcase.css: the hero, the steps, the questions and the launcher); this file
++ only places them. Colours come from the Frame's theme tokens, so it follows the person's theme, light or dark. */
.page { --hm-accent-text: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary, #e6edf7)); }
.sc-eyebrow { color: var(--hm-accent-text); }
.sc-table thead th { color: var(--text-secondary, #96a7c2); }
--/* Numbers from the catalog and the running config. */
--.hm-stats-sec { margin-top: 40px; }
--.hm-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(128px, 100%), 1fr)); }
--.hm-stats a { display: grid; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; transition: border-color .15s; }
--.hm-stats a:hover, .hm-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
--.hm-stats b { font-size: 30px; line-height: 1.1; letter-spacing: -.02em; }
--.hm-stats span { font-size: 13.5px; color: var(--text-secondary, #96a7c2); }
++/* The numbers, one strip under the hero. */
++.hm-stats-sec { margin: 22px 0 0; }
--/* Task templates, grouped by what they need. */
--.hm-templates { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
--.hm-tgroup { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 14px; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-secondary, #101828); }
--.hm-tgroup-head { margin: 0 0 2px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
--.hm-template { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border-light, var(--border, #2c3d5c)); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; line-height: 1.45; transition: border-color .15s; }
--.hm-template:hover, .hm-template:focus-visible { border-color: var(--accent, #3b82f6); }
--.hm-template small { font-size: 12.5px; font-weight: 700; color: var(--hm-accent-text); }
--.hm-needs { margin: auto 0 0; font-size: 13px; color: var(--text-secondary, #96a7c2); }
++/* The task box, then the tasks to start from: a rail on a phone; from 760 px a grid that wraps, so it needs no arrows. */
++.hm-task .sc-lede { margin-bottom: 0; }
++.hm-task .ovk-rail { margin-top: 22px; }
++@media (min-width: 760px) {
++ .hm-task .ovk-rail-track { grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); grid-auto-columns: auto; overflow: visible; margin: 0; padding: 2px 0 6px; }
++ .hm-task .ovk-rail-btn { display: none; }
++}
++.hm-needs { margin: 0; font-size: 13.5px; color: var(--text-secondary, #96a7c2); }
.hm-chosen { margin: 0 0 12px; padding: 10px 14px; border-left: 3px solid var(--accent, #3b82f6); border-radius: 0 10px 10px 0; background: var(--bg-primary, #0a0f1c); font-size: 15px; overflow-wrap: anywhere; }
++/* How it works: each part is a fold; a showcase block inside one drops its section spacing. A link to a part (#tools)
++ lands on its anchor inside the fold, which opens it; the margin keeps the fold's title in view under the navbar. */
++.hm-more .ovk-fold-body > div { scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 84px); }
++.hm-more .ovk-fold-body .sc-sec { margin: 0; }
++.hm-more .ovk-fold-body .sc-lede { margin-bottom: 14px; }
++.hm-more .ovk-fold-body > div > p:first-child { margin-top: 0; }
++.hm-api-more { margin: 16px 0 0; }
++
++/* The rest of OpenVibe (showcase.apps): its four places in one row on a phone, not three and one; room before the
++ Frame's "Recently shipped" under it. */
++@media (max-width: 599px) { .hm-net .sc-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
++@media (min-width: 600px) and (max-width: 959px) { .hm-net .sc-apps { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++.hm-net { margin-bottom: 48px; }
++
/* The tools the runtime can run, as chips. */
.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.hm-chips li { min-width: 0; }
.hm-chips a, .hm-chips li:not(:has(a)) { display: inline-block; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border-light, var(--border, #2c3d5c)); background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); font-size: 13.5px; text-decoration: none; overflow-wrap: anywhere; }
.hm-chips a:hover, .hm-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
--/* Questions, answered on the page. */
--.hm-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }
--.hm-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .08)); }
--.hm-qa h3 { margin: 0 0 6px; font-size: 16px; }
--.hm-qa p { margin: 0; font-size: 14.5px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
--
--/* The shared steps and code grids ask for 280–320 px; a 320 px phone has less. */
++/* The shared steps and code grids ask for 280–320 px; a 320 px phone has less (and a fold's padding takes more). */
.sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 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: 420px) { .sc-hero, .sc-sec, .sc-cta { padding-left: 16px; padding-right: 16px; } }
--@media (prefers-reduced-motion: reduce) { .hm-stats a, .hm-template { transition: none; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 115f78d..f1c4541 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -12,6 +12,7 @@
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
@@@ -44,38 +45,41 @@ const EXAMPLES = [
'Is the TLS certificate of example.com valid, and when does it expire?',
];
--// Tasks to start from, by what they need. Each one works today; the robot, computer and private ones say what they need.
++// Tasks to start from, by what they need. Each one works today. On the home they are a rail of cards (kit.card): a short
++// title, the kind as the badge and the card type's icon; the card's link puts the whole task in the task box, ready to run
++// or change. What a robot, computer, desk or private task needs is said in the box once that task is chosen (NEEDS).
const TEMPLATES = [
-- { group: 'Look it up', items: [
-- { text: EXAMPLES[0] },
-- { text: EXAMPLES[3] },
++ { group: 'Look it up', badge: 'Lookup', type: 'domain', items: [
++ { title: 'Check a domain\'s mail setup', text: EXAMPLES[0] },
++ { title: 'Is a site\'s certificate valid?', text: EXAMPLES[3] },
] },
-- { group: 'Research with sources', items: [
-- { text: EXAMPLES[1] },
-- { text: 'Which open-source licences let me sell hosted copies of the software? Compare MIT, Apache-2.0, GPL-3.0 and AGPL-3.0, with sources.' },
++ { group: 'Research with sources', badge: 'Research', type: 'report', items: [
++ { title: 'What changed in the latest Node.js LTS', text: EXAMPLES[1] },
++ { title: 'Which licences let you sell hosted copies', text: 'Which open-source licences let me sell hosted copies of the software? Compare MIT, Apache-2.0, GPL-3.0 and AGPL-3.0, with sources.' },
] },
-- { group: 'Write and explain', items: [
-- { text: EXAMPLES[2] },
-- { text: 'Write a short, friendly note telling my viewers the stream starts an hour later on Friday.', mode: 'cheapest' },
++ { group: 'Write and explain', badge: 'Write', type: 'post', items: [
++ { title: 'A mutex and a semaphore, explained', text: EXAMPLES[2] },
++ { title: 'A note telling viewers the stream moves', text: 'Write a short, friendly note telling my viewers the stream starts an hour later on Friday.', mode: 'cheapest' },
] },
-- { group: 'Across OpenVibe', items: [
-- { text: 'Find recent posts and clips on OpenVibe about Raspberry Pi robots, and link the best three.' },
++ { group: 'Across OpenVibe', badge: 'OpenVibe', type: 'stream', items: [
++ { title: 'The best posts and clips on Pi robots', text: 'Find recent posts and clips on OpenVibe about Raspberry Pi robots, and link the best three.' },
] },
-- { group: 'Your robot', needs: 'Turn on Your agent on the robot\'s panel first.', items: [
-- { text: 'Drive my rover forward for two seconds, turn on its headlights, then say hello.' },
++ { group: 'Your robot', badge: 'Robot', type: 'toy', needs: 'Turn on Your agent on the robot\'s panel first.', items: [
++ { title: 'Drive your rover and say hello', text: 'Drive my rover forward for two seconds, turn on its headlights, then say hello.' },
] },
-- { group: 'Your computer', needs: 'Needs the OpenVibe Node\'s computer plugin on that machine.', items: [
-- { text: 'On my computer, list the ten largest files in my Downloads folder.' },
++ { group: 'Your computer', badge: 'Computer', type: 'site', needs: 'Needs the OpenVibe Node\'s computer plugin on that machine.', items: [
++ { title: 'The ten largest files in your Downloads', text: 'On my computer, list the ten largest files in my Downloads folder.' },
] },
-- { group: 'On a desk of its own', needs: 'Needs a computer or desk host with the OpenVibe Node\'s computer plugin. Watch the desk and take it over from the task.', items: [
-- { text: 'On my computer, open openvibe.live in your own browser on a desk of your own, and tell me who is live right now and what each stream is about.' },
-- { text: 'On my computer, open the sign-in page of <a site I use> on your desk and hand it to me to sign in; then tell me what my account page shows.' },
++ { group: 'On a desk of its own', badge: 'Desk', type: 'cam', needs: 'Needs a computer or desk host with the OpenVibe Node\'s computer plugin. Watch the desk and take it over from the task.', items: [
++ { title: 'Who is live on OpenVibe, and about what', text: 'On my computer, open openvibe.live in your own browser on a desk of your own, and tell me who is live right now and what each stream is about.' },
++ { title: 'Sign in on its desk, then read your account', text: 'On my computer, open the sign-in page of <a site I use> on your desk and hand it to me to sign in; then tell me what my account page shows.' },
] },
-- { group: 'Private', needs: 'Answered on OpenVibe\'s own model; nothing leaves OpenVibe.', items: [
-- { text: 'Summarize the following note in three bullet points, without it leaving OpenVibe:', mode: 'private' },
++ { group: 'Private', badge: 'Private', type: 'person', needs: 'Answered on OpenVibe\'s own model; nothing leaves OpenVibe.', items: [
++ { title: 'Summarize a note without it leaving OpenVibe', text: 'Summarize the following note in three bullet points, without it leaving OpenVibe:', mode: 'private' },
] },
];
const templateHref = (t) => `/?${new URLSearchParams({ task: t.text, ...(t.mode ? { mode: t.mode } : {}) })}#task`;
++const NEEDS = new Map(TEMPLATES.flatMap((g) => (g.needs ? g.items.map((t) => [t.text, g.needs]) : [])));
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
@@@ -92,18 +96,20 @@ function createPageRoutes(ctx) {
// ── The task box ─────────────────────────────────────────
function taskForm(viewer, { text = '', mode = 'balanced', problem = null } = {}) {
++ // What a chosen template needs (a robot, a computer, a desk, private mode) is said where it is about to run.
++ const needs = NEEDS.has(text) ? html`<p class="hm-needs">${NEEDS.get(text)}</p>` : '';
if (!(viewer.kind === 'user' && viewer.subject)) {
// A template picked while signed out waits in the box and comes back after sign-in.
const next = text ? `/?${new URLSearchParams({ task: text, mode })}` : '/#task';
return html`<div class="task-box task-box-guest">
--${text ? html`<p class="task-box-lead">Your task, ready to run once you sign in:</p><blockquote class="hm-chosen">${text}</blockquote>`
++${text ? html`<p class="task-box-lead">Your task, ready to run once you sign in:</p><blockquote class="hm-chosen">${text}</blockquote>${needs}`
: html`<p class="task-box-lead">Sign in with your OpenVibe account and ask in plain words. Not sure what to ask? Start from one of the tasks below.</p>`}
<div class="task-actions"><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(next)}">${text ? 'Sign in to run it' : 'Sign in to start'}</a><span class="muted small">Free: ${tier.tasksPerDay} tasks a day, no card.</span></div></div>`;
}
return html`<form class="task-box" method="post" action="/tasks">
${problem ? notice(problem, 'warn') : ''}
<label class="task-label" for="task">What should Actor do?</label>
--<textarea id="task" name="task" rows="4" maxlength="20000" required placeholder="${EXAMPLES[0]}">${text}</textarea>
++<textarea id="task" name="task" rows="4" maxlength="20000" required placeholder="${EXAMPLES[0]}">${text}</textarea>${needs}
<fieldset class="modes"><legend>Mode</legend>
${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" value="${m}"${m === mode ? raw(' checked') : ''}><span><b>${MODE_LABEL[m]}</b><small>${MODE_TEXT[m]}</small></span></label>`)}
</fieldset>
@@@ -120,6 +126,10 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
return (Array.isArray(list) ? list : []).filter((t) => t && t.id && t.name);
}
++ // The front page is a taste and a door, not the manual (owner 2026-10-28: "act more like an app, not a huge long web
++ // page"): the hero with one sample decision, the numbers as one strip, the task box with tasks to start from as a
++ // rail, the agents as rows, and the parts read once (how routing works, the comparison, what works today, the tools,
++ // the API) as folds. Everything stays on the page, so search engines, the Markdown twin and find-in-page still see it.
async function homePage({ viewer = { kind: 'anonymous' }, query = {}, form = {} } = {}) {
const agents = catalog.list(config);
const tools = await callableTools();
@@@ -131,65 +141,74 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
const hero = showcase.hero({
eyebrow: 'OpenVibe.Actor · your agent for everything · alpha',
title: 'Ask once.', accent: 'The right agent does it.',
-- lede: 'Give Actor a task in plain words. It picks the agent that fits: OpenVibe\'s own, acting through OpenVibe Search and Tools, or another platform\'s, like OpenAI\'s web agent. It runs the task, has a second model check the answer, and shows you what it cost and why that agent. Like OpenRouter, for agents.',
++ lede: 'Give Actor a task in plain words. It picks the agent that fits, OpenVibe\'s own or another platform\'s, has a second model check the answer, and shows you what it cost and why. Like OpenRouter, for agents.',
actions: signed ? [{ label: 'Give it a task', href: '#task', primary: true }, { label: 'Your tasks', href: '/tasks' }] : [{ label: 'Sign in to start', href: '/auth/login?next=%2F', primary: true }, { label: 'See the agents', href: '/agents' }],
-- note: `Open source (AGPL-3.0). Free tier: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each. The list below says exactly what works today.`,
++ note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each, no card.`,
aside: { html: String(routerCard(agents)) },
});
-- const stats = [
-- [working, working === 1 ? 'agent working now' : 'agents working now', '#h-agents'],
-- [tools.length, 'tools it can run', '#h-tools'],
-- [MODES.length, 'ways to choose', '#route'],
++ // One strip of numbers (it scrolls sideways on a phone instead of stacking four tiles); zero is left out.
++ const stats = kit.stats([
++ [working, working === 1 ? 'agent working now' : 'agents working now', '#agents'],
++ [tools.length, 'tools it can run', '#tools'],
++ [MODES.length, 'ways to choose', '/agents#modes'],
[tier.tasksPerDay, 'free tasks a day', '#faq'],
-- ].filter(([n]) => n > 0);
++ ], { label: 'OpenVibe.Actor in numbers' });
++ // Tasks to start from: one card each, the kind as its badge and a non-default mode in its corner.
++ const starts = kit.rail({
++ id: 'templates', title: 'Start from one of these',
++ items: TEMPLATES.flatMap((g) => g.items.map((t) => kit.card({ href: templateHref(t), title: t.title, type: g.type, ratio: '3/1', badge: g.badge, corner: t.mode && MODE_LABEL[t.mode] !== g.badge ? MODE_LABEL[t.mode] : '', peek: t.text, share: false }))),
++ });
++ // Signed out and nothing chosen yet, the hero's Sign in is the door: the box would only repeat it.
++ const box = signed || form.text ? taskForm(viewer, form)
++ : html`<p class="sc-lede"><a href="/auth/login?next=%2F%23task">Sign in</a> with your OpenVibe account and ask in plain words, or pick a task below: it waits in the box while you sign in.</p>`;
++ // A part read once folds (kit.fold). Its anchor sits inside the fold, so a link to it (#tools, #api) opens it.
++ const fold = ({ id, body, ...o }) => raw(kit.fold({ ...o, body: `<div id="${id}">${body}</div>` }));
++ const status = [
++ ['The task router: modes, budgets, the explanation, live progress, cancel', 'Live.'],
++ ['OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools', 'Live.'],
++ ['OpenAI\'s web agent as one of the routed agents', 'Live.'],
++ ['Private mode on an open model on OpenVibe\'s server', 'Live: short answers that never leave OpenVibe; tasks that need search or tools go to the other agents.'],
++ ['A second-family check on every answer, and hand-off on failure', 'Live.'],
++ [html`The API (<a href="/docs">docs</a>): tasks, the live stream, <a href="/docs#webhooks">signed webhooks</a>, the agents and a dry-run router`, 'Live, for people and for apps with actor.task.* grants from OpenVibe.Services.'],
++ [html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],
++ [html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],
++ [html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
++ [html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
++ [html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
++ [html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
++ [html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
++ [html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
++ ];
++ // The rest of OpenVibe as a launcher (four icons in a row on a phone), with the referral tag showcase.network adds.
++ const related = (showcase.RELATED.actor || []).map(([to, what, href]) => ({ icon: `ov:${to}`, name: to.charAt(0).toUpperCase() + to.slice(1), text: what, href: `${href}?ov_from=actor` }));
return {
agent: true, index: true, cache: signed && !query.task ? null : query.task ? cache.htmlHeaders({ private: true }) : PUBLIC_CACHE,
jsonLd: [...homeJsonLd(config),
seo.jsonLd.itemList('Agents OpenVibe.Actor routes tasks to', agents.map((a) => ({ name: a.name, url: `${config.baseUrl}/agents#${a.id}`, description: (catalog.byId(a.id) || {}).blurb }))),
seo.jsonLd.faq(faq.map(([q, a]) => ({ q, a })))],
-- styles: [showcase.STYLESHEET],
++ // kit.css: the numbers, rows, badges and folds; room.css: the cards and the rail. No kit-web.js: no card has a
++ // menu, and from 760 px the rail wraps instead of scrolling, so it needs no arrow buttons.
++ styles: [showcase.STYLESHEET, 'kit.css', 'room.css'],
css: ['css/home.css'],
body: html`${raw(hero)}
--<section class="sc-sec hm-stats-sec" aria-label="OpenVibe.Actor in numbers"><ul class="hm-stats">${stats.map(([n, label, href]) => html`<li><a href="${href}"><b>${n}</b><span>${label}</span></a></li>`)}</ul></section>
--<section class="sc-sec" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
--${taskForm(viewer, form)}</section>
--<section class="sc-sec" aria-labelledby="h-templates"><h2 id="h-templates">Start from one of these</h2>
--<p class="sc-lede">Tasks that work today. Pick one and it lands in the task box, ready to run or change.</p>
--<ul class="hm-templates">${TEMPLATES.map((g) => html`<li class="hm-tgroup"><p class="hm-tgroup-head"><b>${g.group}</b></p>
--${g.items.map((t) => html`<a class="hm-template" href="${templateHref(t)}"><span>${t.text}</span><small>${t.mode ? `${MODE_LABEL[t.mode]} mode · ` : ''}Use this</small></a>`)}
--${g.needs ? html`<p class="hm-needs">${g.needs}</p>` : ''}</li>`)}</ul></section>
--${raw(showcase.features({
-- title: 'What it can do today',
-- lede: 'Each of these is a real agent working through a real API, not a demo.',
-- items: [
-- { icon: 'ov:search', title: 'Search the live web, with sources', text: 'News, prices, releases, anything recent: OpenAI\'s agent searches and cites its sources.' },
-- { icon: 'ov:dns', title: 'Look up any domain or site', text: 'DNS, mail records, SPF and DMARC, certificates, headers, WHOIS: the runtime runs OpenVibe.Tools for real answers.' },
-- { icon: 'ov:network', title: 'Search the OpenVibe network', text: 'Streams, clips, pastes, posts and wiki pages across every OpenVibe site.' },
-- { icon: 'ov:text', title: 'Write and explain', text: 'Drafts, summaries, explanations, plans: the cheapest agent that does it well.' },
-- { icon: 'ov:code', title: 'Work on your own computer', text: 'List and read files, run programs and write files on your computer, at the level you set there: at "ask", nothing runs until you approve it on the computer.' },
-- { icon: 'ov:host', title: 'Private mode', text: 'An open model on OpenVibe\'s own server answers, and nothing leaves OpenVibe.' },
-- { icon: 'ov:check', title: 'Checked before you see it', text: 'A second model reads every answer first, from another model family where one is available (private mode checks on OpenVibe\'s own model); a failed check goes to the next agent.' },
-- ],
-- }))}
--${tools.length ? html`<section class="sc-sec" aria-labelledby="h-tools"><h2 id="h-tools">Tools it can run</h2>
--<p class="sc-lede">For exact answers, OpenVibe's own agent runs these ${tools.length} OpenVibe.Tools tools itself instead of guessing: the same public tools anyone can use on openvibe.tools.</p>
--<ul class="hm-chips hm-tools">${tools.map((t) => html`<li>${/^https:\/\//.test(t.docs || '') ? html`<a href="${t.docs}">${t.name}</a>` : t.name}</li>`)}</ul></section>` : ''}
--${raw(showcase.steps({
-- id: 'route',
-- title: 'How a task finds its agent',
++<div class="hm-stats-sec">${raw(stats)}</div>
++<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>
++<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>
++${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.' },
{ title: 'Hard limits first', text: 'An agent that cannot do it, that private mode does not trust, that is down or that would cost more than your budget is out, and the task says why.' },
{ title: 'Then your mode', text: 'Cheapest, Balanced, Best, Fastest or Private picks among the rest, through the same placement engine every OpenVibe service uses.' },
-- { title: 'Run, check, deliver', text: 'The agent works while you watch its steps; another model checks the answer; you get the answer, its sources, the cost and the explanation.' },
++ { title: 'Run, check, deliver', text: 'The agent works while you watch its steps; another model checks the answer, from another model family where one is available; you get the answer, its sources, the cost and the explanation.' },
{ title: 'Or hand it on', text: 'An agent that fails, or an answer that fails its check, goes to the next agent that can do it, within your budget.' },
],
-- }))}
--<section class="sc-sec" aria-labelledby="h-agents"><h2 id="h-agents">The agents</h2>
--<p class="sc-lede">Every agent Actor can route to, with the price it charges and whether it is working right now. <a href="/agents">Prices, modes and details</a>.</p>
--<div class="agent-cards">${agents.map((a) => agentCard(a))}</div></section>
--${raw(showcase.compare({
-- title: 'Why a router, not one agent',
++ }) })}
++${fold({ id: 'why', title: 'Why a router, not one agent', meta: 'compared', body: showcase.compare({
lede: 'One company\'s agent is as good as that company\'s models and tools. Actor picks per task, and can use theirs too.',
columns: ['OpenVibe.Actor', 'A single-vendor agent'],
rows: [
@@@ -201,37 +220,20 @@ ${raw(showcase.compare({
{ label: 'Open source, self-hostable', values: [true, false] },
{ label: 'Free tier', values: [true, null] },
],
-- }))}
--<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
--${table(['Piece', 'State'], [
-- ['The task router: modes, budgets, the explanation, live progress, cancel', 'Live.'],
-- ['OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools', 'Live.'],
-- ['OpenAI\'s web agent as one of the routed agents', 'Live.'],
-- ['Private mode on an open model on OpenVibe\'s server', 'Live: short answers that never leave OpenVibe; tasks that need search or tools go to the other agents.'],
-- ['A second-family check on every answer, and hand-off on failure', 'Live.'],
-- [html`The API (<a href="/docs">docs</a>): tasks, the live stream, <a href="/docs#webhooks">signed webhooks</a>, the agents and a dry-run router`, 'Live, for people and for apps with actor.task.* grants from OpenVibe.Services.'],
-- [html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],
-- [html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],
-- [html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
-- [html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
-- [html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
-- [html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
-- [html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
-- [html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
-- ])}</section>
--${raw(showcase.code({
-- id: 'api',
-- title: 'Hand it tasks from your own code',
-- lede: 'Every task, its live stream and its webhooks are an API, and the router answers a dry run with no account at all.',
++ }) })}
++${fold({ id: 'status', title: 'What works today', meta: `${status.length} pieces, all live`, body: String(table(['Piece', 'State'], status)) })}
++${tools.length ? fold({ id: 'tools', title: 'Tools it can run', meta: `${tools.length} from OpenVibe.Tools`, body: String(html`<p class="muted">For exact answers, OpenVibe's own agent runs these ${tools.length} OpenVibe.Tools tools itself instead of guessing: the same public tools anyone can use on openvibe.tools.</p>
++<ul class="hm-chips hm-tools">${tools.map((t) => html`<li>${/^https:\/\//.test(t.docs || '') ? html`<a href="${t.docs}">${t.name}</a>` : t.name}</li>`)}</ul>`) }) : ''}
++${fold({ id: 'api', title: 'Hand it tasks from your own code', meta: 'curl, JavaScript', body: showcase.code({
++ lede: 'Every task, its live stream and its webhooks are an API, and the router answers a dry run with no account at all. Apps get actor.task.* grants on OpenVibe.Services.',
samples: [
{ label: 'Which agent, what cost (curl, no token)', lang: 'sh', code: `curl -X POST ${config.baseUrl}/api/v1/route \\\n -H 'Content-Type: application/json' \\\n -d '{"task": "Is the certificate of example.com valid?",\n "mode": "cheapest"}'` },
{ 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}" },
],
-- }))}
--<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
--<div class="hm-faq">${faq.map(([q, a]) => html`<div class="hm-qa"><h3>${q}</h3><p>${a}</p></div>`)}</div></section>
--${raw(showcase.network({ service: 'actor' }))}
--${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
++ }) + 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 }))}
++<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>`,
};
}
@@@ -264,15 +266,6 @@ ${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, i
<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
}
-- function agentCard(a) {
-- return html`<article class="agent-card${a.available ? '' : ' off'}" id="card-${a.id}">
--<header><b>${a.name}</b>${a.available ? badge('working', 'ok') : badge('not here yet')}</header>
--<p>${(catalog.byId(a.id) || {}).blurb || ''}</p>
--<p class="muted small">${priceText(a.rate_card)}${a.model ? html` · <code>${a.model}</code>` : ''}</p>
--${a.available ? '' : html`<p class="muted small">${a.reason}</p>`}
--</article>`;
-- }
--
r.get('/', (req, res) => home(req, res));
// ── Create from the form (POST /tasks) ───────────────────
@@@ -505,7 +498,8 @@ ${table(['Agent', 'Could take it', 'Estimated cost', 'Why not'], (p.candidates |
body: html`<h1>The agents</h1>
<p>Actor is a router for agents, the way OpenRouter is one for models: each task goes to the agent system that can do it, then by your mode. Prices are typed in from each provider's own price page by a person, with the day it was checked; Actor charges the real usage of each call at these prices. Machine-readable: <a href="/api/v1/agents"><code>GET /api/v1/agents</code></a>.</p>
${table(['Agent', 'Kind', 'Can do', 'Trust', 'Price', 'Typical time', 'Now'], agents.map((a) => [
-- html`<strong id="${a.id}">${a.name}</strong><br><code>${a.id}</code>${a.model ? html`<br><span class="muted small">${a.model}</span>` : ''}`,
++ // What each one is (the home lists the agents as rows that link here).
++ html`<strong id="${a.id}">${a.name}</strong><br><code>${a.id}</code>${a.model ? html`<br><span class="muted small">${a.model}</span>` : ''}${(catalog.byId(a.id) || {}).blurb ? html`<p class="agent-blurb">${catalog.byId(a.id).blurb}</p>` : ''}`,
a.kind,
a.can.filter((c) => c.startsWith('task:')).map((c) => c.slice(5)).join(', '),
a.trust,
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 0a3b1cf..53e16e7 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -1,7 +1,9 @@
'use strict';
/**
-- * The home page's data sections (server/http/pages.js): the numbers come from the catalog, the running config and
-- * OpenVibe.Tools' catalog; a template fills the task box (signed out, it waits for sign-in); the tools Actor can run
++ * The home page (server/http/pages.js) is a taste and a door: the numbers as one kit.stats strip, the task box with the
++ * tasks to start from as a rail of kit cards, the agents as rows, the parts read once as folds, the questions and the
++ * rest of OpenVibe as a launcher. The numbers come from the catalog, the running config and OpenVibe.Tools' catalog; a
++ * template fills the task box (signed out, it waits for sign-in) with what it needs said there; the tools Actor can run
* are only the callable ones; the FAQPage JSON-LD holds exactly the questions the page shows.
*/
const assert = require('assert');
@@@ -11,33 +13,64 @@ const { boot, check, done } = require('./helpers/boot');
const t = await boot();
const user = t.network.addUser('kim');
const section = (html, id) => (new RegExp(`<section class="sc-sec[^"]*"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(html) || [''])[0];
++ // A fold's body: its anchor (id) sits inside it, so a link to the anchor opens the fold.
++ const foldOf = (html, id) => (new RegExp(`<details class="ovk-fold"><summary>[\\s\\S]*?</summary><div class="ovk-fold-body"><div id="${id}">[\\s\\S]*?</details>`).exec(html) || [''])[0];
++ const stat = (label, value = '\\d+') => new RegExp(`<dt><a href="[^"]+">${label}</a></dt><dd>${value}</dd>`);
-- await check('the stat row: agents working, tools it can run, modes, the free tier', async () => {
++ await check('one strip of numbers: agents working, tools it can run, modes, the free tier', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<b>1<\/b><span>tools it can run<\/span>/, 'only the callable tool (mx), not the job tool');
-- assert.match(r.text, /<b>5<\/b><span>ways to choose<\/span>/);
-- assert.match(r.text, /<b>\d+<\/b><span>free tasks a day<\/span>/);
-- assert.match(r.text, /<b>\d+<\/b><span>agents? working now<\/span>/);
-- const tools = section(r.text, 'h-tools');
++ assert.strictEqual((r.text.match(/<dl class="ovk-stats"/g) || []).length, 1, 'the numbers are one kit.stats strip');
++ assert.match(r.text, stat('tools it can run', '1'), 'only the callable tool (mx), not the job tool');
++ assert.match(r.text, /<dt><a href="#tools">tools it can run<\/a><\/dt>/, 'the tools number opens the tools fold');
++ assert.match(r.text, stat('ways to choose', '5'));
++ assert.match(r.text, stat('free tasks a day'));
++ assert.match(r.text, stat('agents? working now'));
++ const tools = foldOf(r.text, 'tools');
assert.match(tools, />MX Lookup</);
assert.ok(!tools.includes('Video download'), 'a job tool is not offered');
assert.match(r.text, /<link rel="stylesheet" href="\/css\/home\.css\?v=[0-9a-f]{10}">/);
++ // The shared kit draws it: kit.css (numbers, rows, folds) and room.css (cards, the rail), and no kit-web.js.
++ assert.match(r.text, /<link rel="stylesheet" href="\/shared\/kit\.css\?v=/);
++ assert.match(r.text, /<link rel="stylesheet" href="\/shared\/room\.css\?v=/);
++ assert.ok(!/\/shared\/kit-web\.js/.test(r.text), 'no kit-web.js: no card menus, and the rail wraps on wide screens');
++ });
++
++ await check('the parts read once are folds; the agents are rows that open /agents; the rest of OpenVibe is a launcher', async () => {
++ const r = await t.get('/');
++ // The folds hold showcase sections of their own, so the part runs from its heading to the questions.
++ const more = r.text.slice(r.text.indexOf('<h2 id="h-how">'), r.text.indexOf('<section class="sc-sec" id="faq"'));
++ for (const id of ['route', 'why', 'status', 'tools', 'api']) assert.ok(foldOf(more, id), `the ${id} part is a fold`);
++ assert.ok(!/<details class="ovk-fold" open/.test(more), 'every fold starts closed');
++ assert.match(foldOf(more, 'status'), /OpenAI&#39;s web agent as one of the routed agents|OpenAI's web agent as one of the routed agents/, 'what works today is all still there');
++ assert.match(foldOf(more, 'api'), /\/api\/v1\/route/, 'the API sample');
++ const agents = section(r.text, 'h-agents');
++ const listed = (await t.get('/api/v1/agents')).json().agents;
++ for (const a of listed) assert.ok(agents.includes(`<a href="/agents#${a.id}">`), `${a.id} is a row that opens its place on /agents`);
++ const page = await t.get('/agents');
++ assert.match(page.text, /<p class="agent-blurb">/, 'what each agent is now lives on /agents');
++ const launcher = (/<section class="sc-sec sc-apps-sec" id="network"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
++ for (const host of ['openvibe.tools', 'openvibe.watch', 'openvibe.bot', 'openvibe.codes']) assert.ok(launcher.includes(`<a href="https://${host}/?ov_from=actor">`), `the launcher links ${host}`);
++ assert.ok(!/class="(hm-template|hm-tgroup|agent-card|hm-qa)\b/.test(r.text), 'no hand-built card stacks are left');
});
await check('templates fill the task box; signed out, the chosen task waits for sign-in', async () => {
const home = await t.get('/');
-- const templates = section(home.text, 'h-templates');
-- const links = [...templates.matchAll(/class="hm-template" href="([^"]+)"/g)].map((m) => m[1].replace(/&amp;/g, '&'));
-- assert.ok(links.length >= 8, 'one link per template');
++ const templates = (/<section class="ovk-rail[^"]*" id="templates"[\s\S]*?<\/section>/.exec(home.text) || [''])[0];
++ const links = [...templates.matchAll(/class="ovk-card-link" href="([^"]+)"/g)].map((m) => m[1].replace(/&amp;/g, '&'));
++ assert.ok(links.length >= 8 && links.length <= 12, `the tasks to start from are a rail of 8 to 12 cards (${links.length})`);
++ assert.ok(!home.text.includes('task-box-guest'), 'signed out with nothing chosen, the hero\'s Sign in is the door (no second box)');
++ assert.match(section(home.text, 'h-task'), /<a href="\/auth\/login\?next=%2F%23task">Sign in<\/a>/);
const privateOne = links.find((l) => l.includes('mode=private'));
assert.ok(privateOne, 'the private template names its mode');
const signedIn = await t.get(privateOne.replace(/#.*$/, ''), { as: user });
assert.match(signedIn.text, /<textarea id="task"[^>]*>Summarize the following note in three bullet points, without it leaving OpenVibe:<\/textarea>/);
assert.match(signedIn.text, /<input type="radio" name="mode" value="private" checked>/);
++ assert.match(signedIn.text, /<p class="hm-needs">Answered on OpenVibe&#39;s own model; nothing leaves OpenVibe\.<\/p>/, 'what the chosen task needs is said in the box');
const guest = await t.get('/?task=Explain%20DNS&mode=nope');
assert.match(guest.text, /<blockquote class="hm-chosen">Explain DNS<\/blockquote>/);
assert.match(guest.text, /href="\/auth\/login\?next=%2F%3Ftask%3DExplain%2BDNS%26mode%3Dbalanced">Sign in to run it</);
++ assert.ok(!guest.text.includes('class="hm-needs"'), 'a task that is not a template needs nothing said');
assert.match(guest.text, /<link rel="canonical" href="https:\/\/openvibe\.actor\/">/, 'a template link is still the home page');
assert.match(guest.headers.get('cache-control') || '', /private/, 'a prefilled page is never kept by a shared cache');
});
@@@ -48,8 +81,8 @@ const { boot, check, done } = require('./helpers/boot');
const list = ld.find((x) => x['@type'] === 'ItemList');
const agents = (await t.get('/api/v1/agents')).json().agents;
assert.strictEqual(list.numberOfItems, agents.length);
-- const faq = section(r.text, 'h-faq');
-- const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
++ const shown = [...faq.matchAll(/<summary>([^<]+)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
assert.ok(shown.length >= 6);
assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, 'one h1');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 25f7b32..0ce052d 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -20,9 +20,9 @@ const BUDGETS = {
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 305, // 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: 76, // 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: 3, // 2 (actor.css + the cached /shared/showcase.css); 3 with home.css, the home's own sections (10-22)
-- cssRawKB: 46, // 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: 9.5, // 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) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
++ 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)
externalFiles: 0, // 0
};