openvibers/openvibe.tools Commits

The home shows live use, every tool in one click, and the discovery API (#254)

OP OpenVibers committed 4 days ago · 6c8066986e · 28 files, +703 −106

* The home shows live use, every tool in one click, and the discovery API

The headline keeps "Small tools that" and rotates through the most used
tools' jobs, with the reduced-motion rule (one static line) and a pause
control. Live numbers come from Tools' own successful API runs: uses today,
the trending tools this hour with a rising arrow, the most popular over
seven days, the ones trending this week and the newest tools.

The quick links become icon chips in a drifting strip, the rankings and every
category become kit rows, and each category lists all of its tools as static
links with an ItemList JSON-LD, so every tool is one click from the home and
crawlable. "Ask Vibe which tool" routes a description to the right tool.

GET /api/v1/tools/popular and /api/v1/tools/trending rank successful runs
from the new tool_usage_hourly table (migration 0012). Counts keep no inputs,
addresses or actors.

* Ask Vibe which tool: one clear winner opens the tool, a close call searches

The route now normalises the words ("shrink a picture" is the compressor,
"a domain's DNS" is not a stray s) and scores them with the catalogue's own
scoring. One tool at least half again ahead of the runner-up opens; two
close candidates go to the server-rendered search, so the box never opens a
tool the words do not clearly name.

* Merge remote-tracking branch 'origin/main' into feature/tools-home-live

# Conflicts:
#	apps/gateway/test/perf-budget.test.js

* The perf budget follows openvibe-shared 4.163.0 and the kit's own files

The home now loads the kit's stylesheet and behaviour file, so the measured
page is 206 KB of HTML and 361.2 KB of JavaScript (86.2 KB Brotli); the
budgets sit just above those numbers.

* The screenshots follow openvibe-shared 4.163.0

The same page, the same measurements, taken with the kit's current stylesheet
and behaviour file.

* The documented sizes follow the measurement

* Tools home: designed (Opus): categories as counted folds, the headline's pause as a small icon

- Every category is a fold titled with its icon, name and "38 tools": open where there is room, closed on a phone, every
  tool still a static link in the page (the phone home went from 17 900 px to 4 300 px; JSON-LD unchanged).
- The headline's pause is a 36 px round icon beside the heading (pause / play, its label in aria-label and title), not a
  "Pause headline" line of its own; it sits outside the h1 so the heading's words stay the heading's.
- foldBlock takes a count in words.

Changes

ddiff --git a/apps/_shared/jobs/system.js b/apps/_shared/jobs/system.js
iindex 33eb9d7..2b207ac 100644
---- a/apps/_shared/jobs/system.js
++++ b/apps/_shared/jobs/system.js
@@@ -130,6 +130,11 @@ function createJobSystem(o) {
const row = await store.get(id);
await announce.finished(t, row);
await usage.finished(row);
++ if (row.state === 'succeeded' && row.tool) {
++ // The public discovery count never breaks a job: a failed counter write is logged and dropped.
++ try { await require('../tools/discovery-usage').recordUse(t, row.tool); }
++ catch (err) { log.warn('[jobs] discovery count unavailable: ' + (err && err.message ? err.message : 'unknown')); }
++ }
}
const types = new Map();
ddiff --git a/apps/_shared/test/job-usage.test.js b/apps/_shared/test/job-usage.test.js
iindex 14237d7..6b15128 100644
---- a/apps/_shared/test/job-usage.test.js
++++ b/apps/_shared/test/job-usage.test.js
@@@ -101,6 +101,7 @@ function define(system) {
const failedId = await run({ project: PRJ, type: 'test.boom', traceId: TRACE, files: [{ buffer: Buffer.from('x'), name: SECRET_FILE, mime: 'text/plain' }] }, 'failed');
await run({ project: PRJ, env: 'sandbox' }, 'succeeded');
await run({ project: PRJ, tool: 'image-resize' }, 'succeeded');
++ assert.deepStrictEqual(await db.many('SELECT tool, sum(uses)::int AS uses FROM tool_usage_hourly GROUP BY tool'), [{ tool: 'image-resize', uses: 1 }], 'public discovery counts the successful tool job, not unlabelled or failed jobs');
await run({ project: null, owner: `user:${USER}` }, 'succeeded'); // a person's job: no project, not counted
assert.strictEqual((await system.get(failedId)).trace_id, TRACE, 'the submit request\'s trace id is kept with the job');
const { job: held } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { hold: true }, project: PRJ });
ddiff --git a/apps/_shared/tools/discovery-usage.js b/apps/_shared/tools/discovery-usage.js
nnew file mode 100644
iindex 0000000..dd32095
---- /dev/null
++++ b/apps/_shared/tools/discovery-usage.js
@@@ -0,0 +1,10 @@
++'use strict';
++// Each completed job counts in its completion transaction. Inline replays bypass this hook.
++// Cache hits count as successful uses; failed/cancelled work and page opens do not.
++async function recordUse(db, tool, at = Date.now()) {
++ if (!db || !tool) return;
++ await db.exec(`INSERT INTO tool_usage_hourly (tool, hour, uses) VALUES ($1, $2, 1)
++ ON CONFLICT (tool, hour) DO UPDATE SET uses = tool_usage_hourly.uses + 1`,
++ [tool, Math.floor(at / 3600000)]);
++}
++module.exports = { recordUse };
ddiff --git a/apps/_shared/tools/run.js b/apps/_shared/tools/run.js
iindex 350da50..d34edf9 100644
---- a/apps/_shared/tools/run.js
++++ b/apps/_shared/tools/run.js
@@@ -195,6 +195,7 @@ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
* @param {Function} [o.parseJson] JSON body parser middleware, when the app has not run one
* @param {object} [o.jobs] a satellite's jobs: { system: () => system, receive: (d) => middleware,
* fetchSibling: (ref, req) → file | null }
++ * @param {(tool: string) => Promise<void>} [o.onSuccess] optional inline success counter; replays bypass it
* @param {object} [o.log]
*/
function createRunApi(o) {
@@@ -309,6 +310,9 @@ function createRunApi(o) {
}
}
if (replayKey) replays.set(replayKey, { hash: reqHash, body }, REPLAY_TTL_MS);
++ if (body.state === 'succeeded' && o.onSuccess) {
++ try { await o.onSuccess(d.id); } catch { log.warn('[tools] discovery count unavailable'); }
++ }
return send(res, 200, body);
}
ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 661da84..142b2d9 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -169,3 +169,31 @@
}).catch(function () { /* optional */ });
var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
})();
++
++// Keep one server-rendered phrase for reduced motion; changes never enter an aria-live region.
++(function () {
++ const el = document.getElementById('tool-headline');
++ if (!el) return;
++ const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
++ let phrases; try { phrases = JSON.parse(el.dataset.phrases); } catch { return; }
++ if (!Array.isArray(phrases) || phrases.length < 2) return;
++ let i = 0, timer, paused = false;
++ const control = document.getElementById('headline-motion');
++ const update = () => {
++ clearInterval(timer);
++ if (control) control.hidden = motion.matches;
++ if (!motion.matches && !paused && !document.hidden) timer = setInterval(() => {
++ i = (i + 1) % phrases.length; el.textContent = phrases[i] + '.';
++ }, 4500);
++ if (motion.matches) { i = 0; el.textContent = phrases[0] + '.'; }
++ };
++ if (control) control.addEventListener('click', () => {
++ paused = !paused;
++ const say = paused ? 'Play the headline' : 'Pause the headline';
++ control.setAttribute('aria-label', say); control.title = say; control.classList.toggle('is-paused', paused);
++ update();
++ });
++ motion.addEventListener('change', update);
++ document.addEventListener('visibilitychange', update);
++ update();
++})();
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 4b8b4df..f6d383c 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -228,6 +228,8 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req
// guard's quotas on runs and on the jobs facade's submits, and the admin analytics route.
const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'gateway', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
app.use(actorLimits.registryReads);
++const discovery = require('./registry/discovery').createDiscovery({ db: toolsDb.db, registry });
++app.use(discovery.router());
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
@@@ -292,6 +294,7 @@ const { createJobsFacade, createJobIndex } = require('./run/jobs-facade');
const { ajvFrom } = require('../../_shared/tools/run');
const jobIndex = createJobIndex();
const runApi = createGatewayRun({
++ onSuccess: (id) => require('../../_shared/tools/discovery-usage').recordUse(toolsDb.db, id),
guard, contracts: require('openvibe-contracts'), toolRegistry, routers: { net: netRouter, dev: devRouter },
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
});
@@@ -461,7 +464,7 @@ app.use(require('./codes/image').router());
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
// This site's own pinned copy of the OpenVibe Frame's browser files (/shared, openvibe-shared/serve) is
// mounted with the release routes: apps/_shared/release.js.
--app.use(require('./pages/site').createSiteRouter({ opens: toolOpens }));
++app.use(require('./pages/site').createSiteRouter({ opens: toolOpens, discovery }));
// Net tool subdomains → net.html SPA
app.use((req, res, next) => {
ddiff --git a/apps/gateway/server/openapi.js b/apps/gateway/server/openapi.js
iindex 1f4a0eb..8a5e25b 100644
---- a/apps/gateway/server/openapi.js
++++ b/apps/gateway/server/openapi.js
@@@ -56,6 +56,21 @@ function openapi(snapshot, site) {
'/api/v1/jobs/{id}/files/{n}': { get: { operationId: 'jobFile', summary: 'Download result file n', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam, { name: 'n', in: 'path', required: true, schema: { type: 'integer', minimum: 0 } }], responses: { 200: { description: 'The file' }, 404: problem } } },
'/api/v1/jobs/{id}/retry': { post: { operationId: 'retryJob', summary: 'Retry a failed job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The new job', content: { 'application/json': { schema: ref('job') } } }, 409: problem } } },
};
++ for (const kind of ['popular', 'trending']) paths[`/api/v1/tools/${kind}`] = { get: {
++ operationId: kind === 'popular' ? 'popularTools' : 'trendingTools', tags: ['registry'],
++ summary: kind === 'popular' ? 'Tools ranked by successful API uses over seven days' : 'Tools with rising successful API use',
++ description: 'Public aggregate counts; no inputs or actors. Cache hits count, idempotent replays do not. UTC hourly buckets; current hour is partial. Week compares the last 168 buckets with the preceding 168. Refreshed every minute.',
++ parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 50, default: 12 } },
++ { name: 'window', in: 'query', schema: { type: 'string', enum: kind === 'popular' ? ['week'] : ['hour','week'], default: 'week' } }],
++ responses: { 200: { description: 'Ranked tools; empty when no use is recorded', content: { 'application/json': { schema: {
++ type: 'object', required: ['metric','window','updated_at','uses_today','tools','count'],
++ properties: { metric: { const: 'successful_api_runs' }, window: { enum: ['hour','week'] }, updated_at: { type: 'string', format: 'date-time' }, uses_today: { type: 'integer', minimum: 0 }, count: { type: 'integer' },
++ tools: { type: 'array', items: { type: 'object', required: ['id','name','description','family','icon','url','uses'], properties: {
++ ...Object.fromEntries(['id','name','description','family','icon'].map(k => [k, { type: 'string' }])), url: { type: 'string', format: 'uri' }, uses: { type: 'integer', minimum: 1 }, previous_uses: { type: 'integer', minimum: 0 }, increase: { type: 'integer', minimum: 1 },
++ } } },
++ },
++ } } } }, 400: { description: 'Invalid limit or window' }, 503: { description: 'Usage unavailable; never presented as zero' } },
++ } };
// Every code as an image URL (server/codes/image.js): no account, the same URL always draws the same image.
const q = (name, description, schema = { type: 'string' }) => ({ name, in: 'query', required: false, description, schema });
paths['/api/codes'] = { get: { operationId: 'listCodeSymbologies', summary: 'Every barcode and QR symbology the image URLs draw, with an example each', tags: ['codes'], responses: { 200: { description: '{ usage, aliases, symbologies: [{ id, name, example }] }' } } } };
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex d060386..c79af14 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -17,6 +17,7 @@ const social = require('openvibe-shared/social');
const express = require('express');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
++const kit = require('openvibe-shared/kit');
const appIcon = require('openvibe-shared/app-icon');
const registry = require('../registry');
const { seoFor } = require('../seo/catalog');
@@@ -42,6 +43,10 @@ function toolCard(t) {
}
const CSS = `
++.quick-strip{display:flex;gap:8px;overflow-x:auto;position:relative;padding:12px 0;max-width:100%}
++.quick-strip .ovk-btn{flex:none;min-height:44px;text-decoration:none}
++.hero-copy{min-width:0}.search .ovk-btn{min-height:44px}
++.jobs .ovk-list{margin:0}.jobs .more{display:inline-flex;align-items:center;min-height:44px}
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.55 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
a{color:var(--accent-light,var(--accent,#60a5fa))}
@@@ -96,10 +101,6 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.jump a{display:inline-flex;align-items:center;gap:7px;flex:none;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));text-decoration:none;color:var(--text-primary,#e6edf7);font-size:13.5px;font-weight:600;background:var(--bg-secondary,#111826)}
.jump a:hover{border-color:var(--accent,#3b82f6)}.jump i{font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);font-weight:700}
.fam{scroll-margin-top:120px}.fam h2 a{color:inherit;text-decoration:none}.fam h2 a:hover{color:var(--accent-light,var(--accent,#60a5fa))}.fam-tag{margin:2px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14.5px}.fam .sec-h{margin-bottom:14px;align-items:center}
--.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:8px}
--.tile{position:relative;display:flex;gap:10px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.07));background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;min-width:0;transition:border-color .15s,background .15s}
--.tile:hover,.tile:focus-visible{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,var(--bg-secondary,#111826));outline:0}
--.tile>span:not(.ov-icon){min-width:0}.tile b{display:block;font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile small{display:block;font-size:12px;color:var(--text-muted,#7d8aa0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fold details{border:0;padding:0;max-width:none}.fold summary{list-style:none;display:flex;align-items:center;gap:10px}.fold summary::-webkit-details-marker{display:none}.fold summary::after{content:'+';margin-left:auto;font-size:22px;font-weight:400;color:var(--text-muted,#7d8aa0)}.fold details[open] summary::after{content:'\\2212'}.fold summary h2{display:inline}.fold details>:not(summary){margin-top:12px}
/* 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
@@@ -117,6 +118,17 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.fold-body{display:none}
.fold-toggle:checked~.fold-body{display:block}
.fold-block:not(.fold-bare) .fold-body .ov-shipped-home h3{display:none}
++/* Categories as folds (11-09): the icon and the name are the fold's title; its count sits beside them. */
++.job .fold-block{margin-top:10px}.job .fold-h .sec-h{display:flex;align-items:center;gap:10px;flex:1 1 auto;min-width:0;margin:0}.job .fold-h h2{margin:0}
++.job{border-top:1px solid var(--border,rgba(255,255,255,.08));padding-top:4px}
++/* The headline's pause: a small round icon beside the words, not a line of its own. */
++.hm-title{display:flex;align-items:flex-start;gap:10px}.hm-title h1{flex:1 1 auto;min-width:0}.hm-title .hm-motion{margin-top:8px;flex:none}
++.hm-motion{display:inline-grid;place-items:center;width:36px;height:36px;margin:-4px 0 0;padding:0;border:0;border-radius:50%;vertical-align:middle;cursor:pointer;
++background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent);color:var(--text-secondary,#a8b3c4)}
++.hm-motion::after{content:"";position:absolute;inset:-4px}.hm-motion{position:relative}
++.hm-motion svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
++.hm-motion .hm-play{display:none;fill:currentColor;stroke:none}.hm-motion.is-paused .hm-pause{display:none}.hm-motion.is-paused .hm-play{display:block}
++.hm-motion:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
@media (min-width:641px){
.fold-block{margin-top:40px}
.fold-toggle{display:none}
@@@ -126,7 +138,7 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
}
.fam-all{margin:10px 0 0;font-size:14px}.fam-all a{font-weight:650;text-decoration:none}
.perks{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.perks div{padding:16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826)}.perks p{margin:6px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14px}
--@media (max-width:560px){.tiles{grid-template-columns:1fr 1fr}.tile small{display:none}.tile b{white-space:normal;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.jump{top:52px}}
++@media (max-width:560px){.jump{top:52px}}
#ov-footer{margin-top:56px}
.tool .opens{color:var(--text-secondary,#a8b3c4);font-weight:600}
.api-box{max-width:820px}.api-box pre{background:var(--bg-secondary,#111826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;padding:12px 14px;font-size:13px;line-height:1.5}
@@@ -157,8 +169,6 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.hero-copy h1{font-size:clamp(30px,4.4vw,48px);line-height:1.06;letter-spacing:-.035em;margin:0 0 12px;font-weight:800;max-width:16ch}
.hero-copy .hero-sub{max-width:56ch;margin:0 0 20px;color:var(--text-secondary,#a8b3c4);font-size:clamp(15px,1.5vw,17px)}
.hero-split .search{max-width:560px;margin:0}
--.hero-links{margin:12px 0 0;font-size:13.5px;line-height:1.7;color:var(--text-muted,#7d8aa0)}
--.hero-links a{font-weight:600;white-space:nowrap}
.hero-split+.stats{justify-content:flex-start;margin:10px 0 0}
.promo{min-width:0;border:1px solid var(--border,rgba(255,255,255,.1));background:var(--bg-secondary,#111826);border-radius:18px;padding:clamp(16px,2vw,22px);box-shadow:0 12px 40px rgba(0,0,0,.16)}
.promo-head h2{font-size:19px;letter-spacing:-.01em;margin:0}
@@@ -189,23 +199,15 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.flow .flow-out{border-color:var(--accent,#3b82f6);color:var(--text-primary,#e6edf7);font-weight:600}
.flow .flow-arrow{border:0;background:none;padding:0;color:var(--text-muted,#7d8aa0)}
.job{padding:clamp(24px,4vw,36px) 0 0}
--.jobs-h{display:none}
++.jobs-h{display:flex;margin-top:30px}
.sec-count{font-size:13px;font-weight:650;color:var(--text-muted,#7d8aa0);white-space:nowrap;font-variant-numeric:tabular-nums}
--/* Phones (the owner's rule, 2026-10-31: never hide content to shorten): every job stays on the page, and a job with
-- more than four tools shows them as a sideways rail of two rows, so the page stays short and nothing is hidden. */
++/* Phones (the owner's rule, 2026-10-31: never hide content to shorten): every category stays on the page with all of
++ its tools, so the list is a plain column of rows that a thumb can scroll through. */
@media (max-width:640px){
--.jobs-h{display:flex;margin-top:30px}
.jobs .job{padding-top:22px}
.jobs .job .sec-p{margin:2px 0 0;font-size:13.5px}
--.jobs .job .tiles:has(>.tile:nth-child(5)){grid-template-columns:none;grid-template-rows:repeat(2,auto);grid-auto-flow:column;grid-auto-columns:calc((100% - 16px) / 2.3);overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x proximity;scroll-padding-inline:16px;margin:12px -16px 0;padding:0 16px 2px;scrollbar-width:none}
--.jobs .job .tiles:has(>.tile:nth-child(5))::-webkit-scrollbar{display:none}
--.jobs .job .tiles>.tile{scroll-snap-align:start}
--.jobs .job .group-links{margin-top:10px}
}
.job .sec-h{margin-bottom:4px}
--.job .tiles{margin-top:14px}
--.group-links{display:flex;flex-wrap:wrap;gap:6px 18px;margin:12px 0 0;font-size:13.5px;font-weight:600}
--.group-links a{text-decoration:none;white-space:nowrap}
@media (max-width:820px){.hero.hero-split{grid-template-columns:minmax(0,1fr);gap:20px}.hero-copy h1{max-width:none}.start-grid{grid-template-columns:1fr}}
@media (max-width:560px){.grid{grid-template-columns:minmax(0,1fr);gap:6px}.search button{padding:0 14px}.tool{padding:9px 11px;align-items:center}.tool small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tool i{display:none}.hero{padding-top:20px}.hero h1{font-size:28px}.kicker{display:none}}
/* The home on phones (2026-10-29): the examples are one row that scrolls sideways (all four still there), and four of the most
@@@ -218,7 +220,6 @@ ${icons.CSS}
main.wrap summary, .fold summary { min-height: 44px; }
.sec-h a.more { position: relative; display: inline-block; }
.sec-h a.more::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
-- .group-links a { min-height: 44px; display: inline-flex; align-items: center; }
.promo-out dt a { display: inline-block; min-height: 44px; }
.promo-foot a { position: relative; display: inline-block; }
.promo-foot a::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
@@@ -255,6 +256,7 @@ ${appIcon.headTags({ site: 'tools', iconBase: '/assets' })}
<link rel="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">
<link rel="preconnect" href="https://openvibe.network">
<script src="${ovServe.url('theme-loader.js')}" defer></script>
++<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<style>${CSS}</style></head><body>${spriteSvg}
<div id="navbar-mount"></div><noscript><style>#navbar-mount{display:none}</style><header class="topbar"><a href="${SITE}/">${NAME}</a><nav>${families.filter(f => f.path).map(f => `<a href="${esc(f.path)}">${esc(f.name)}</a>`).join('')}<a href="/all-tools">All tools</a></nav></header></noscript>
<main class="wrap">${body}
@@@ -265,7 +267,7 @@ ${foldBlock({
<div class="net">${NETWORK.map(([ic, n, d, u]) => `<a class="tool" href="${u}">${icon(ic, 36)}<span class="tool-t"><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>`,
})}
</main>${frame.footer({ service: 'tools', variant: 'full', updates: '/updates' })}
--<script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script><script src="/js/site.js" defer></script>
++<script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script><script src="${ovServe.url('kit-web.js')}" defer></script><script src="/js/site.js" defer></script>
</body></html>`;
}
@@@ -275,13 +277,15 @@ function head(o) {
return seo.headTags(Object.assign({ siteName: NAME, image: `${SITE}${social.url('tools')}`, type: 'website', locale: 'en_US' }, o));
}
--const searchForm = (q) => `<form class="search" action="/search" method="get" role="search"><input type="search" name="q" id="q" value="${esc(q || '')}" placeholder="What do you need to do? Try “merge pdf” or “dns lookup”" aria-label="Search tools" autocomplete="off"><button type="submit">Search</button></form>`;
++const searchForm = (q) => `<form class="search" action="/search" method="get" role="search"><input type="search" name="q" id="q" value="${esc(q || '')}" placeholder="What do you need to do? Try “merge pdf” or “dns lookup”" aria-label="Search tools" autocomplete="off">${kit.button({ label: 'Search', icon: 'search' })}</form>`;
function score(t, terms) {
let s = 0; const name = t.name.toLowerCase(); const hay = [t.tagline, t.description, ...(t.keywords || []), t.id].join(' ').toLowerCase();
for (const w of terms) { if (name.includes(w)) s += 5; if (t.id === w) s += 4; if ((t.keywords || []).some(k => k.includes(w))) s += 3; else if (hay.includes(w)) s += 1; else if (!name.includes(w)) return 0; }
return s;
}
++// The words a person does not need in a tool request ("I want to merge some PDFs"): they only blur the scoring.
++const ASK_STOP = ['i','a','an','the','my','your','to','want','need','please','help','me','can','you','with','which','tool','do','how','make','some','for','of','find','near','nearby','around'];
function search(q) {
const terms = String(q || '').toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
if (!terms.length) return [];
@@@ -382,30 +386,7 @@ const HOME_FAQ = [
];
// ── Pages ────────────────────────────────────────────────────
--// The home groups the suite by the job a person came to do, not by internal family names. Every
--// family page stays linked, and every tool stays one tap from the group it lives in.
--const JOB_GROUPS = [
-- { id: 'files', name: 'Images and documents', line: 'Shrink a picture, merge a PDF, convert a scan: one file in, the file you need out.', families: ['img', 'docs'] },
-- { id: 'text', name: 'Text and design', line: 'Fancy fonts, clean-ups, counters, barcodes and simple logos you can download.', families: ['text', 'codes'] },
-- { id: 'audio', name: 'Audio', line: 'Convert, trim, join and clean up recordings.', families: ['audio'] },
-- { id: 'media', name: 'Video', line: 'Save a YouTube video, or just its sound, for offline use.', families: ['media'] },
-- { id: 'data', name: 'Data and code', line: 'Format JSON, decode tokens, test regex and move data between formats.', families: ['dev'] },
-- { id: 'web', name: 'Web and network', line: 'Check DNS, domains, certificates, ports and mail servers.', families: ['net'] },
-- { id: 'places', name: 'Maps and food', line: 'Find water, shelter, supplies and an affordable meal nearby.', families: ['places'] },
--];
--/**
-- * The job sections as one block. Every section stays on the page at every width (the owner's rule, 2026-10-31: never
-- * hide content to shorten). On a phone a job with more than four tools shows them as a sideways rail of two rows (CSS
-- * above), under one "Tools by job" heading that links the full list.
-- */
--function jobsBlock(sections) {
-- if (!sections.length) return '';
-- return `<div class="jobs"><div class="sec-h jobs-h"><h2>Tools by job</h2><a class="more" href="/all-tools">All tools</a></div>${sections.join('')}</div>`;
--}
--const QUICK_LINKS = [['mergepdf', 'merge a PDF'], ['fancy', 'fancy text'], ['compress', 'compress an image'], ['json', 'format JSON'], ['dns', 'check DNS']];
--const FAMILY_SHORT = { img: 'image tools', docs: 'PDF tools', text: 'text tools', codes: 'barcode tools', audio: 'audio tools', media: 'video tools', dev: 'developer tools', net: 'network tools', places: 'map tools' };
--
--function pageIndex(top = []) {
++function pageIndex(top = [], activity = { available: false }) {
// Only built tools: a home never shows what is planned (plan §1 "no placeholders").
const { tools, families } = registry.get();
const byId = new Map(tools.map(t => [t.id, t]));
@@@ -414,9 +395,6 @@ function pageIndex(top = []) {
// Most opened this week (registry/opens.js): real counts, shown once at least four tools have some.
const opened = top.map((r) => [byId.get(r.tool), r.opens]).filter(([t, n]) => t && n > 0).slice(0, 6);
const openedCard = ([t, n]) => toolCard(t).replace('</small><i>', `</small><i><span class="opens">${n.toLocaleString('en-US')} ${n === 1 ? 'person' : 'people'} this week</span> · `);
-- const tile = (t) => `<a class="tile" href="${esc(t.url)}"${go(t)} title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;
-- // Hand-picked first, then registry order; eight is enough to scan, the family links carry the rest.
-- const pick = (list) => [...list.filter((t) => POPULAR.includes(t.id)), ...list.filter((t) => !POPULAR.includes(t.id))].slice(0, 8);
const startLink = (t) => `href="${esc(t.url)}"${go(t)}`;
const startCard = (id, inner) => { const t = byId.get(id); return t ? `<a class="start" ${startLink(t)}>${inner(t)}</a>` : ''; };
const host = (t) => esc(hostLabel(t.hosts.short || t.hosts.canonical));
@@@ -447,14 +425,30 @@ function pageIndex(top = []) {
</aside>` : '';
const jsonIn = '{"b":1,"a":2}';
const jsonOut = JSON.stringify(JSON.parse(jsonIn), null, 2);
-- const quick = QUICK_LINKS.map(([id, label]) => { const t = byId.get(id); return t ? `<a href="${esc(t.url)}"${go(t)}>${esc(label)}</a>` : ''; }).filter(Boolean);
++ const available = tools.filter(t => t.status !== 'unavailable');
++ const rankedIds = (activity.popular || []).map(t => t.id);
++ const quickTools = [...new Set([...rankedIds, ...POPULAR, ...available.map(t => t.id)])].map(id => byId.get(id)).filter(t => t && t.status !== 'unavailable').slice(0, 12);
++ const quick = quickTools.map(t => kit.button({ href: t.url, label: t.name, kind: 'quiet' }).replace('<span>', icon(t.icon, 20) + '<span>'));
++ const phrases = { yt: 'save a video', logo: 'make a logo', ssl: 'check a TLS certificate', trim: 'trim your audio', regex: 'test a regular expression', maps: 'find places nearby', food: 'find a meal nearby', mergepdf: 'merge your PDFs', dns: "check a domain’s DNS", compress: 'shrink an image', json: 'format your JSON', jsonminify: 'minify your JSON', fancy: 'restyle your text', whois: 'look up a domain', convert: 'convert your images', resize: 'resize your images', mp3: 'convert your audio' };
++ const headlines = quickTools.slice(0, 6).map(t => phrases[t.id] || `help with ${t.name.toLowerCase()}`);
++ const headline = headlines[0] || 'finish the job';
++ const rows = (items, trend = false) => `<ul class="ovk-list">${items.slice(0,6).map(t => { const n = Number(t.uses || 0); return kit.row({ href: t.url, mark: icon(t.icon, 28), title: t.name, sub: t.description, meta: t.added_at ? `Added ${t.added_at}` : `${n.toLocaleString('en-US')} ${n === 1 ? 'use' : 'uses'}`, trail: trend ? `<span aria-label="Up ${t.increase} uses">↗ +${t.increase}</span>` : '' }); }).join('')}</ul>`;
++ // One ranking as a section of its own: the heading, the line that says what the numbers mean, then the rows.
++ const rankedSection = (title, items, { trend = false, note = '' } = {}) => `<section><h2>${title}</h2>${note ? `<p class="sec-p">${note}</p>` : ''}${items && items.length ? rows(items, trend) : `<p class="sec-p">${activity.available ? 'No recorded activity in this window yet.' : 'Usage is temporarily unavailable.'}</p>`}</section>`;
++ const discoveryHtml = `<section aria-labelledby="live-h"><h2 id="live-h">Live usage</h2><p class="sec-p">${activity.available ? `<b>${Number(activity.uses_today || 0).toLocaleString('en-US')}</b> successful uses today (UTC).` : 'Live usage is temporarily unavailable.'} Counts cover successful API runs, including completed file jobs. Browser-only edits and page opens are not counted. Updated every minute.</p></section>
++${rankedSection('Trending this hour', activity.hour, { trend: true, note: 'Tools whose use rose over the last hour compared with the hour before it.' })}
++${rankedSection('Most popular', activity.popular, { note: 'Successful uses over the last seven days.' })}
++${rankedSection('Trending this week', activity.week, { trend: true, note: 'Rising use compared with the preceding seven days.' })}
++${activity.newest && activity.newest.length ? `<section><h2>Newest tools</h2>${rows(activity.newest)}</section>` : ''}`;
const body = `<div class="hero hero-split">
<div class="hero-copy"><p class="kicker">Open source · community-run · no account needed to start</p>
--<h1>Small tools that <span>finish the job.</span></h1>
++<div class="hm-title"><h1>Small tools that <span id="tool-headline" data-phrases="${esc(JSON.stringify(headlines))}">${esc(headline)}.</span></h1><button id="headline-motion" class="hm-motion" type="button" aria-label="Pause the headline" title="Pause the headline" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path class="hm-pause" d="M9 6v12M15 6v12"/><path class="hm-play" d="M8 5.5v13l11-6.5z"/></svg></button></div>
<p class="hero-sub">Convert a file, merge a PDF, format JSON, check DNS: ${tools.length} tools that open in the browser and get out of your way. Free, and no sign-up to look.</p>
${searchForm('')}
--<p class="hero-links">Straight to it: ${quick.join(' · ')}</p></div>
++<nav class="quick-strip" data-ovk-scroll="drift" aria-label="${rankedIds.length ? 'Popular tools' : 'Explore tools'}">${quick.join('')}</nav></div>
${demo}</div>
++<section aria-labelledby="ask-tool-h"><h2 id="ask-tool-h">Ask Vibe which tool</h2><p class="sec-p">Describe your task to find a matching tool in this catalog.</p><form class="search" action="/ask-tool" method="get"><input name="q" aria-label="What would you like to do?" placeholder="I want to merge PDFs" maxlength="160" required>${kit.button({ label: 'Find a tool', icon: 'search' })}</form></section>
++${discoveryHtml}
<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>${withApi}</b> with an API</li><li>nothing to install</li></ul>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section>
@@@ -471,17 +465,22 @@ ${opened.length >= 4 ? foldBlock({
head: `<h2 id="top-h">Most opened this week <span class="fold-count">${opened.length}</span></h2>`,
html: `<section aria-labelledby="top-h"><p class="sec-p">How many people opened each tool in the last seven days, counted once per person a day.</p><div class="grid">${opened.map(openedCard).join('')}</div></section>`,
}) : ''}
--${jobsBlock(JOB_GROUPS.map((job) => {
-- const list = job.families.flatMap((fid) => tools.filter((t) => t.family === fid));
++<div class="jobs"><div class="sec-h jobs-h"><h2>Tools by category</h2><a class="more" href="/all-tools">All tools</a></div>${families.map(f => {
++ const list = tools.filter(t => t.family === f.id);
if (!list.length) return '';
-- const groupFamilies = job.families.map((fid) => families.find((f) => f.id === fid)).filter((f) => f && f.path && tools.some((t) => t.family === f.id));
-- return `<section class="job" id="job-${job.id}" aria-labelledby="job-${job.id}-h"><div class="sec-h"><h2 id="job-${job.id}-h">${esc(job.name)}</h2><span class="sec-count">${list.length} ${list.length === 1 ? 'tool' : 'tools'}</span></div><p class="sec-p">${esc(job.line)}</p><div class="tiles">${pick(list).map(tile).join('')}</div><p class="group-links">${groupFamilies.map((f) => { const n = tools.filter((t) => t.family === f.id).length; const label = FAMILY_SHORT[f.id] || f.name.toLowerCase(); return `<a href="${esc(f.path)}">${n === 1 ? `The ${label.replace(/s$/, '')}` : `All ${n} ${label}`} →</a>`; }).join('')}</p></section>`;
-- }).filter(Boolean))}
++ // Each category is a counted fold (the owner's short-pages rule): every tool stays in the page as a static link for
++ // readers and crawlers, open where there is room and closed on a phone (184 tools made the phone home 18 000 px).
++ return `<section class="job" id="job-${esc(f.id)}" aria-labelledby="job-${esc(f.id)}-h">${foldBlock({
++ id: `job-${f.id}`, count: `${list.length} ${list.length === 1 ? 'tool' : 'tools'}`,
++ head: `<span class="sec-h">${icon(f.icon, 28)}<h2 id="job-${esc(f.id)}-h">${esc(f.name)}</h2></span>`,
++ html: `<p class="sec-p">${esc(f.tagline)}</p><ul class="ovk-list">${list.map(t => kit.row({ href: t.url, mark: icon(t.icon, 28), title: t.name, sub: t.tagline })).join('')}</ul>${f.path ? `<a class="more" href="${esc(f.path)}">More about ${esc(f.name)}</a>` : ''}`,
++ })}</section>`;
++ }).join('')}</div>
<section class="fold" aria-labelledby="dev-h"><details><summary><h2 id="dev-h">Use any tool from code</h2></summary><div class="sec-h"><a class="more" href="/developers">Developer guide</a></div><p class="sec-p">${withApi} of the ${tools.length} tools take one request each, the same engines the pages use. Each tool's page shows its own request.</p>
<div class="dev"><pre tabindex="0"><code>curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
-H 'Content-Type: application/json' \\
-d '{"input":{"text":"{ \\"a\\": 1 }"}}'</code></pre>
--<ul><li><a href="/api/v1/openapi.json">OpenAPI 3.1</a>, generated from the tool descriptors</li><li><a href="/api/v1/tools">/api/v1/tools</a>: every tool's inputs, limits and access</li><li>Long work runs as a job you can follow over server-sent events</li><li><a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a>: <code>tools.run(id, input)</code> from JavaScript</li></ul></div></details></section>
++<ul><li><a href="/api/v1/openapi.json">OpenAPI 3.1</a>, generated from the tool descriptors</li><li><a href="/api/v1/tools">/api/v1/tools</a>: every tool's inputs, limits and access</li><li>Long work runs as a job you can follow over server-sent events</li><li><a href="https://openvibe.design/kit">OpenVibe.Design components</a> power the buttons and lists</li><li><a href="/api/v1/tools/popular">Popular tools API</a> · <a href="/api/v1/tools/trending">Trending tools API</a></li><li><a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a>: <code>tools.run(id, input)</code> from JavaScript</li></ul></div></details></section>
${foldBlock({
id: 'shipped',
head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Tools</h2>',
@@@ -500,7 +499,7 @@ ${foldBlock({
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
jsonLd: [seo.jsonLd.organization(), seo.jsonLd.website({ name: NAME, url: SITE + '/', description: 'Online tools for files, text, code and networks.', searchUrl: SITE + '/search?q={search_term_string}' }),
-- seo.jsonLd.itemList('Tool families', fams.map(f => ({ name: f.name, url: SITE + f.path, description: f.tagline }))),
++ ...families.filter(f => tools.some(t => t.family === f.id)).map(f => seo.jsonLd.itemList(f.name, tools.filter(t => t.family === f.id).map(t => ({ name: t.name, url: t.url, description: t.tagline })), { url: f.path ? SITE + f.path : undefined })),
seo.jsonLd.faq(HOME_FAQ.map(([q, a]) => ({ q, a })))] }) });
}
@@@ -607,7 +606,8 @@ function pageDevelopers() {
<section><div class="sec-h"><h2>Discover</h2></div><p class="sec-p">Every tool is described by a machine-readable descriptor (inputs as JSON Schema, file limits, how it runs, who may call it).</p>
<ul><li><a href="/api/v1/tools">/api/v1/tools</a>: the registry (filter with <code>?family=</code>, <code>?q=</code>, <code>?api=true</code>)</li>
<li><code>/api/v1/tools/{id}</code> and <code>/api/v1/tools/{id}/schema</code>: one tool</li>
--<li><a href="/api/v1/openapi.json">/api/v1/openapi.json</a>: OpenAPI 3.1, generated from the descriptors</li></ul></section>
++<li><a href="/api/v1/openapi.json">/api/v1/openapi.json</a>: OpenAPI 3.1, generated from the descriptors</li>
++<li><a href="/api/v1/tools/popular">/api/v1/tools/popular</a>: the tools people use most over the last seven days, and <a href="/api/v1/tools/trending">/api/v1/tools/trending</a>: the ones whose use is rising (<code>?window=hour</code> or <code>week</code>)</li></ul></section>
<section><div class="sec-h"><h2>Run a tool</h2></div>
${code(`curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
-H 'Content-Type: application/json' \\
@@@ -639,7 +639,7 @@ function send(req, res, key, render, type, extra) {
if (isAgentPage) res.set('Link', seo.linkHeaderTags(`${SITE}${key === '/' ? '/' : key}`, `${SITE}/llms.txt`).header);
}
res.set('Content-Type', type || 'text/html; charset=utf-8');
-- res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400');
++ res.set('Cache-Control', key === '/' ? 'public, max-age=60' : 'public, max-age=300, stale-while-revalidate=86400');
res.set('ETag', e.etag);
if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
res.send(e.html);
@@@ -700,7 +700,7 @@ function llmsFullBody() {
sections: llmsSections().map(s => ({ title: s.title, pages: s.links.map(l => ({ title: l.title, url: l.url, text: l.text })) })) });
}
--function createSiteRouter({ opens = null } = {}) {
++function createSiteRouter({ opens = null, discovery = null } = {}) {
const router = express.Router();
// One canonical address per tool: its primary host. The directory's /tool/<id> page is only a pointer, so it
// 301s there (query kept) instead of competing with the tool's own self-canonical page. Its Markdown twin keeps
@@@ -736,7 +736,29 @@ function createSiteRouter({ opens = null } = {}) {
// fails leaves that section out, never the page.
router.get('/', async (req, res) => {
const top = opens ? await opens.top(7, 12).catch(() => []) : [];
-- send(req, res, '/', () => pageIndex(top), undefined, opens ? opens.version() : '');
++ const activity = discovery ? await discovery.snapshot().catch(() => ({ available: false })) : { available: false };
++ send(req, res, '/', () => pageIndex(top, activity), undefined, (opens ? opens.version() : '') + JSON.stringify(activity));
++ });
++ // "Ask Vibe which tool": a description in, the right tool out. The words are normalised, then the catalogue's
++ // own scoring picks the candidates. One clear winner (at least half again the runner-up) opens the tool; anything
++ // close, or nothing at all, goes to the server-rendered search with the same words — never a wrong tool.
++ router.get('/ask-tool', (req, res) => {
++ const raw = String(req.query.q || '').slice(0, 160).toLowerCase();
++ const q = raw
++ .replace(/'s\b/g, '')
++ .replace(/\b(shrink|smaller|reduce|lighten)\b/g, 'compress')
++ .replace(/\b(pdfs|documents|doc)\b/g, 'pdf')
++ .replace(/\b(pictures|picture|photo|photos|pic)\b/g, 'image')
++ .replace(/\b(vids|clips)\b/g, 'video')
++ .split(/[^a-z0-9]+/).filter(w => w && !ASK_STOP.includes(w)).join(' ');
++ res.set('Cache-Control', 'no-store');
++ const terms = q.split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
++ const ranked = terms.length
++ ? registry.get().tools.map(t => [score(t, terms), t]).filter(x => x[0] > 0 && x[1].status !== 'unavailable')
++ .sort((a, b) => b[0] - a[0] || a[1].name.localeCompare(b[1].name))
++ : [];
++ if (ranked.length && (ranked.length === 1 || ranked[0][0] >= ranked[1][0] * 1.5)) return res.redirect(302, ranked[0][1].url);
++ return res.redirect(302, '/search?q=' + encodeURIComponent(q));
});
router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));
router.get('/search', (req, res) => { res.set('Cache-Control', 'no-store'); res.type('html').send(pageSearch(String(req.query.q || '').slice(0, 80))); });
ddiff --git a/apps/gateway/server/registry/discovery.js b/apps/gateway/server/registry/discovery.js
nnew file mode 100644
iindex 0000000..ffbaafd
---- /dev/null
++++ b/apps/gateway/server/registry/discovery.js
@@@ -0,0 +1,62 @@
++'use strict';
++const express = require('express');
++const HOUR = 3600000;
++const METRIC = 'successful_api_runs';
++// Launch evidence: Tools PR #98 (09718c0), merged 2026-10-25 UTC. Dates are authored,
++// never inferred from registry order, a restart or a first page view. Add future launches here.
++const LAUNCHES = { scan: '2026-10-25', datamatrix: '2026-10-25', pdf417: '2026-10-25', aztec: '2026-10-25', gs1: '2026-10-25', bulkbarcodes: '2026-10-25' };
++function createDiscovery({ db, registry, now = Date.now }) {
++ let memo, pending;
++ async function snapshot() {
++ const at = now();
++ if (memo && at - memo.at < 60000 && memo.registry === registry.get().updated) return memo.value;
++ if (pending) return pending;
++ pending = (async () => {
++ const hour = Math.floor(at / HOUR), today = Math.floor(hour / 24) * 24;
++ // Last 168 UTC buckets versus the preceding 168; current hour versus preceding hour.
++ const rows = await db.many(`SELECT tool,
++ COALESCE(sum(uses) FILTER (WHERE hour >= $2), 0)::float8 AS week,
++ COALESCE(sum(uses) FILTER (WHERE hour < $2), 0)::float8 AS previous_week,
++ COALESCE(sum(uses) FILTER (WHERE hour >= $3), 0)::float8 AS today,
++ COALESCE(sum(uses) FILTER (WHERE hour = $4), 0)::float8 AS current_hour,
++ COALESCE(sum(uses) FILTER (WHERE hour = $4 - 1), 0)::float8 AS previous_hour
++ FROM tool_usage_hourly WHERE hour >= $1 AND hour <= $4 GROUP BY tool`,
++ [hour - 335, hour - 167, today, hour]);
++ const tools = registry.get().tools.filter(t => t.status !== 'unavailable');
++ const byId = new Map(tools.map(t => [t.id, t]));
++ const item = t => ({ id: t.id, name: t.name, description: t.tagline, family: t.family, icon: t.icon, url: t.url });
++ const valid = rows.filter(r => byId.has(r.tool));
++ const ranked = (field, previous) => valid.filter(r => Number(r[field]) > 0 && (!previous || Number(r[field]) > Number(r[previous])))
++ .map(r => ({ ...item(byId.get(r.tool)), uses: Number(r[field]), ...(previous ? { previous_uses: Number(r[previous]), increase: Number(r[field]) - Number(r[previous]) } : {}) }))
++ .sort((a,b) => (previous ? b.increase - a.increase : b.uses - a.uses) || b.uses - a.uses || a.id.localeCompare(b.id));
++ const value = { available: true, metric: METRIC, updated_at: new Date(at).toISOString(),
++ uses_today: valid.reduce((sum,r) => sum + Number(r.today), 0),
++ popular: ranked('week'), week: ranked('week', 'previous_week'), hour: ranked('current_hour', 'previous_hour'),
++ newest: tools.filter(t => LAUNCHES[t.id]).map(t => ({ ...item(t), added_at: LAUNCHES[t.id] })).sort((a,b) => b.added_at.localeCompare(a.added_at) || a.id.localeCompare(b.id)) };
++ memo = { at, registry: registry.get().updated, value };
++ return value;
++ })();
++ try { return await pending; } finally { pending = null; }
++ }
++ function router() {
++ const r = express.Router();
++ for (const kind of ['popular', 'trending']) r.get(`/api/v1/tools/${kind}`, async (req,res) => {
++ res.set('Access-Control-Allow-Origin', '*');
++ const limit = req.query.limit === undefined ? 12 : Number(req.query.limit);
++ const window = req.query.window === undefined ? 'week' : req.query.window;
++ if (!Number.isInteger(limit) || limit < 1 || limit > 50 || !['hour','week'].includes(window) || (kind === 'popular' && window !== 'week')) return res.status(400).json({ error: 'Use limit 1–50 and window hour or week (popular: week only).' });
++ try {
++ const s = await snapshot();
++ const items = s[kind === 'popular' ? 'popular' : window].slice(0,limit);
++ res.set('Cache-Control','public, max-age=60');
++ res.json({ metric: METRIC, window, updated_at: s.updated_at, uses_today: s.uses_today, tools: items, count: items.length });
++ } catch {
++ res.set('Cache-Control','no-store');
++ res.status(503).json({ error: 'Tool usage is temporarily unavailable.' });
++ }
++ });
++ return r;
++ }
++ return { snapshot, router };
++}
++module.exports = { createDiscovery, LAUNCHES };
ddiff --git a/apps/gateway/server/run/index.js b/apps/gateway/server/run/index.js
iindex 45193b3..57ac5c1 100644
---- a/apps/gateway/server/run/index.js
++++ b/apps/gateway/server/run/index.js
@@@ -145,6 +145,7 @@ function createGatewayRun(o) {
snapshot: () => o.toolRegistry.snapshot(),
validate: createInputValidator(o.Ajv, o.addFormats),
parseJson: o.parseJson,
++ onSuccess: o.onSuccess,
runs(d) {
const k = kind(d);
return k === 'engine' || k === 'route' ? 'inline' : null;
ddiff --git a/apps/gateway/server/short.js b/apps/gateway/server/short.js
iindex d9d5248..338914f 100644
---- a/apps/gateway/server/short.js
++++ b/apps/gateway/server/short.js
@@@ -37,13 +37,13 @@ function foldMeta(body, cls = '') {
return text ? `<span${cls ? ` class="${cls}"` : ''}>${text}</span>` : '';
}
--function foldBlock({ id, head, html, bare = false }) {
++function foldBlock({ id, head, html, bare = false, count = null }) { // count: the title row's count in words (else counted from the html)
const key = `fold-${esc(id)}`;
// The class is `fold-block`, not `fold`: this site already has a `fold` of its own (the <details> fold in the
// developer block), and the two must not share their rules.
return `<div class="fold-block${bare ? ' fold-bare' : ''}" id="${key}">`
+ `<input class="fold-toggle" type="checkbox" id="${key}-open">`
-- + `<label class="fold-h" for="${key}-open">${str(head)}${foldMeta(str(html), 'fold-n')}<span class="fold-mark" aria-hidden="true"></span></label>`
++ + `<label class="fold-h" for="${key}-open">${str(head)}${count != null ? `<span class="fold-n">${esc(count)}</span>` : foldMeta(str(html), 'fold-n')}<span class="fold-mark" aria-hidden="true"></span></label>`
+ `<div class="fold-body">${str(html)}</div></div>`;
}
ddiff --git a/apps/gateway/test/discovery.test.js b/apps/gateway/test/discovery.test.js
nnew file mode 100644
iindex 0000000..f2decad
---- /dev/null
++++ b/apps/gateway/test/discovery.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++const assert = require('node:assert/strict');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const express = require('express');
++const { testDb, closeAllTestDbs } = require('../../_shared/test/testdb');
++const { recordUse } = require('../../_shared/tools/discovery-usage');
++const { createDiscovery } = require('../server/registry/discovery');
++const registry = require('../server/registry');
++const { createToolsApi } = require('../../_shared/tools/http');
++const site = require('../server/pages/site');
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-discovery-'));
++ let srv;
++ try {
++ const db = await testDb(tmp);
++ const hour = 3600000;
++ let now = Date.parse('2026-11-09T10:30:00Z');
++ const add = async (id, offset, n) => { for (let i = 0; i < n; i++) await recordUse(db, id, now + offset * hour); };
++ await add('dns', 0, 4); await add('dns', -1, 1); await add('dns', -168, 2);
++ await add('json', -1, 6); await add('json', 0, 1); await add('json', -168, 10);
++ await add('missing-tool', 0, 20); await add('whois', -336, 99); await add('whois', 1, 99);
++ await assert.rejects(db.tx(async t => { await recordUse(t, 'dns', now); throw new Error('rollback'); }), /rollback/);
++ const service = createDiscovery({ db, registry, now: () => now });
++ const s = await service.snapshot();
++ assert.equal(s.uses_today, 12, 'UTC today excludes stale, future and unknown tools');
++ assert.deepEqual(s.popular.map(t => [t.id,t.uses]), [['json',7],['dns',5]]);
++ assert.deepEqual(s.hour.map(t => [t.id,t.increase]), [['dns',3]]);
++ assert.deepEqual(s.week.map(t => [t.id,t.increase]), [['dns',3]]);
++ assert.ok(s.newest.every(t => t.added_at === '2026-10-25'));
++ await add('dns', 0, 1);
++ assert.equal((await service.snapshot()).uses_today, 12, 'one-minute memo');
++ now += 60001;
++ assert.equal((await service.snapshot()).uses_today, 13);
++ const app = express();
++ app.use(service.router());
++ app.use(createToolsApi({ snapshot: () => ({ tools: [], updated_at: new Date(now).toISOString() }) }));
++ app.use(site.createSiteRouter({ discovery: service }));
++ srv = app.listen(0, '127.0.0.1'); await new Promise(r => srv.once('listening', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ let r = await fetch(base + '/api/v1/tools/trending?window=hour&limit=1');
++ assert.equal(r.status,200); assert.equal(r.headers.get('access-control-allow-origin'), '*');
++ assert.equal((await r.json()).tools[0].id, 'dns');
++ for (const q of ['limit=0','limit=51','limit=no','window=day']) assert.equal((await fetch(base + '/api/v1/tools/trending?' + q)).status,400);
++ const html = await (await fetch(base + '/')).text();
++ assert.match(html, /13<\/b> successful uses today/); assert.match(html, /↗ \+4/);
++ // "Ask Vibe which tool": one clear winner opens the tool, anything close goes to the search.
++ r = await fetch(base + '/ask-tool?q=I%20want%20to%20merge%20PDFs', { redirect: 'manual' });
++ assert.equal(r.status,302); assert.ok(r.headers.get('location').includes('pdf'), 'the merge tool opens');
++ r = await fetch(base + '/ask-tool?q=shrink%20a%20picture', { redirect: 'manual' });
++ assert.equal(r.status,302); assert.ok(r.headers.get('location').includes('compress'), 'a picture is an image, so the compressor opens');
++ r = await fetch(base + '/ask-tool?q=unknownzz', { redirect: 'manual' });
++ assert.equal(r.headers.get('location'), '/search?q=unknownzz');
++ r = await fetch(base + '/ask-tool?q=check%20dns', { redirect: 'manual' });
++ assert.ok(r.headers.get('location').startsWith('/search?q='), 'two close candidates go to the search, never to the wrong tool');
++ const bad = createDiscovery({ db: { many: async () => { throw Error('down'); } }, registry });
++ const badApp = express(); badApp.use(bad.router());
++ const failSrv = badApp.listen(0,'127.0.0.1'); await new Promise(r => failSrv.once('listening',r));
++ try { r = await fetch(`http://127.0.0.1:${failSrv.address().port}/api/v1/tools/popular`); assert.equal(r.status,503); assert.equal(r.headers.get('cache-control'),'no-store'); } finally { failSrv.close(); }
++ console.log('discovery: PostgreSQL windows, transactions, cache, routing and errors passed');
++ } finally { if (srv) srv.close(); await closeAllTestDbs(); fs.rmSync(tmp,{ recursive:true,force:true }); }
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/apps/gateway/test/home-jobs.test.js b/apps/gateway/test/home-jobs.test.js
iindex b4e4aab..7158826 100644
---- a/apps/gateway/test/home-jobs.test.js
++++ b/apps/gateway/test/home-jobs.test.js
@@@ -1,37 +1,21 @@
'use strict';
--// The home's job sections as one block. The owner's rule (2026-10-31): never hide content to shorten a page. Every job
--// section is shown at every width, each says how many tools it has, and on a phone a job with more than four tools is a
--// sideways rail of two rows. No chips, no radio inputs, no section hidden.
const assert = require('assert');
--const http = require('http');
--const express = require('express');
const site = require('../server/pages/site');
--
--(async () => {
-- const app = express();
-- app.use(site.createSiteRouter());
-- const srv = http.createServer(app);
-- await new Promise((r) => srv.listen(0, '127.0.0.1', r));
-- const base = `http://127.0.0.1:${srv.address().port}`;
-- try {
-- const home = await (await fetch(`${base}/`)).text();
-- const block = home.slice(home.indexOf('<div class="jobs">'), home.indexOf('<section class="fold" aria-labelledby="dev-h">'));
-- assert.ok(block.startsWith('<div class="jobs">'), 'the jobs block is on the home');
-- const sections = block.match(/<section class="job" id="job-([a-z]+)"/g).map((s) => /job-([a-z]+)/.exec(s)[1]);
-- assert.ok(sections.length >= 5, `the job sections are all in the page (${sections.length})`);
-- assert.doesNotMatch(home, /type="radio"|job-pick|jp-/, 'no chips pick one job at a time');
-- assert.match(block, /^<div class="jobs"><div class="sec-h jobs-h"><h2>Tools by job<\/h2><a class="more" href="\/all-tools">All tools<\/a><\/div>/);
-- for (const id of sections) {
-- const sec = block.slice(block.indexOf(`<section class="job" id="job-${id}"`));
-- const m = /<span class="sec-count">(\d+) tools?<\/span>/.exec(sec.slice(0, sec.indexOf('</div>')));
-- assert.ok(m && Number(m[1]) > 0, `${id} says how many tools it has`);
-- }
-- // The CSS: no rule hides a job section, and the rail is the phone's layout for a job of five tools or more.
-- assert.doesNotMatch(home, /\.jobs? \.job\{display:none/, 'no job section is hidden');
-- assert.match(home, /@media \(max-width:640px\)\{\n\.jobs-h\{display:flex/);
-- assert.match(home, /\.jobs \.job \.tiles:has\(>\.tile:nth-child\(5\)\)\{[^}]*grid-auto-flow:column[^}]*overflow-x:auto/, 'a rail scrolls sideways');
-- console.log('home jobs: all checks passed');
-- } finally {
-- srv.close();
-- }
--})().catch((e) => { console.error(e); process.exit(1); });
++const registry = require('../server/registry');
++const html = site.pageIndex();
++const { tools, families } = registry.get();
++for (const f of families) {
++ const list = tools.filter(t => t.family === f.id);
++ if (!list.length) continue;
++ const block = html.split(`id="job-${f.id}"`)[1].split('</section>')[0];
++ assert.ok(block.includes(`<h2 id="job-${f.id}-h">`), f.id + ' has a real heading');
++ for (const t of list) assert.ok(block.includes(`href="${t.url.replace(/&/g, '&amp;')}"`), t.id + ' is one click from home');
++}
++const scripts = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap(m => { const v = JSON.parse(m[1]); return Array.isArray(v) ? v : [v]; });
++const lists = scripts.filter(s => s['@type'] === 'ItemList');
++assert.equal(lists.length, families.length);
++assert.equal(lists.reduce((sum,l) => sum + l.itemListElement.length, 0), tools.length);
++assert.match(html, /data-ovk-scroll="drift"/);
++assert.match(html, /Ask Vibe which tool/);
++assert.match(html.split('<h2>Most popular</h2>')[1].split('</section>')[0], /Usage is temporarily unavailable/);
++console.log('home categories: all tools and category JSON-LD passed');
ddiff --git a/apps/gateway/test/home-short.test.js b/apps/gateway/test/home-short.test.js
iindex 7200156..31004c4 100644
---- a/apps/gateway/test/home-short.test.js
++++ b/apps/gateway/test/home-short.test.js
@@@ -30,7 +30,7 @@ const site = require('../server/pages/site');
// What the home keeps: the search, the good places to start and the jobs.
assert.match(home, /<form class="search"/, 'the search is where it was');
assert.match(home, /<h2 id="start-h">Good places to start<\/h2>/, 'the good places to start are where they were');
-- assert.match(home, /<div class="jobs"><div class="sec-h jobs-h"><h2>Tools by job<\/h2>/, 'the jobs are where they were');
++ assert.match(home, /<div class="jobs"><div class="sec-h jobs-h"><h2>Tools by category<\/h2>/, 'the full category directory remains on the home');
// A fold says what it holds (the owner's rule, 2026-10-31: folds show their count).
assert.match(home, /<h2 id="faq-h">Questions <span class="fold-count">\d+<\/span><\/h2>/, 'the questions fold shows its count');
// The CSS: the fold is closed on a phone and open where there is room.
ddiff --git a/apps/gateway/test/perf-budget.test.js b/apps/gateway/test/perf-budget.test.js
iindex 407c57c..c5cbac1 100644
---- a/apps/gateway/test/perf-budget.test.js
++++ b/apps/gateway/test/perf-budget.test.js
@@@ -10,12 +10,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const { startApp } = require('../../_shared/test/spawn');
const BUDGETS = {
-- htmlRawKB: 160, // measured 135.5 (production; the catalogue is server-rendered)
-- htmlBrotliKB: 23, // 19.5
++ htmlRawKB: 225, // 206 measured with all 184 crawlable tool rows; room for populated ranking rows
++ htmlBrotliKB: 29, // 26.5 measured with the complete category ItemLists
jsFiles: 6, // 5
-- jsRawKB: 345.1, // 345.1: measured 337.1 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
-- jsBrotliKB: 82.3, // 82.3: measured 80.3 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
-- cssFiles: 1, // 0
++ jsRawKB: 365, // 361.2: the Frame plus the kit's behaviour file (kit-web.js) for the drifting strip, 2026-11-09
++ jsBrotliKB: 88, // 86.2 with openvibe-shared 4.163.0, 2026-11-09
++ cssFiles: 1, // 1: the kit's stylesheet
externalFiles: 3, // 1 in production
};
ddiff --git a/apps/gateway/test/run-api.test.js b/apps/gateway/test/run-api.test.js
iindex cf2c9f4..f28617e 100644
---- a/apps/gateway/test/run-api.test.js
++++ b/apps/gateway/test/run-api.test.js
@@@ -73,6 +73,10 @@ function checkProblem(r, status, code, where) {
let r = await run('jsonminify', { input: { text: '{ "a": [1, 2] }' } });
assert.strictEqual(r.status, 200, r.text);
checkRun(r.body, 'jsonminify');
++ const ranked = await call('/api/v1/tools/popular', { method: 'GET' });
++ assert.strictEqual(ranked.status, 200, ranked.text);
++ assert.strictEqual(ranked.body.tools.find(t => t.id === 'jsonminify').uses, 1);
++ assert.strictEqual(ranked.body.metric, 'successful_api_runs');
assert.deepStrictEqual([r.body.state, r.body.tool, r.body.result], ['succeeded', 'jsonminify', { text: '{"a":[1,2]}' }]);
assert.strictEqual(r.h.get('cache-control'), 'no-store');
assert.ok(Number.isInteger(r.body.took_ms));
ddiff --git a/docs/screenshots/tools-home/1280-dark.png b/docs/screenshots/tools-home/1280-dark.png
nnew file mode 100644
iindex 0000000..643991a
BBinary files /dev/null and b/docs/screenshots/tools-home/1280-dark.png differ
ddiff --git a/docs/screenshots/tools-home/1280-light.png b/docs/screenshots/tools-home/1280-light.png
nnew file mode 100644
iindex 0000000..d46d55d
BBinary files /dev/null and b/docs/screenshots/tools-home/1280-light.png differ
ddiff --git a/docs/screenshots/tools-home/390-dark.png b/docs/screenshots/tools-home/390-dark.png
nnew file mode 100644
iindex 0000000..fe71713
BBinary files /dev/null and b/docs/screenshots/tools-home/390-dark.png differ
ddiff --git a/docs/screenshots/tools-home/390-light.png b/docs/screenshots/tools-home/390-light.png
nnew file mode 100644
iindex 0000000..9cd800a
BBinary files /dev/null and b/docs/screenshots/tools-home/390-light.png differ
ddiff --git a/docs/screenshots/tools-home/checks.json b/docs/screenshots/tools-home/checks.json
nnew file mode 100644
iindex 0000000..8d2e79c
---- /dev/null
++++ b/docs/screenshots/tools-home/checks.json
@@@ -0,0 +1,371 @@
++{
++ "url": "http://127.0.0.1:8794/",
++ "note": "Local preview of the gateway; the counts come from real local API runs. The failed requests are the local origin asking openvibe.network for a theme, which its CORS refuses.",
++ "results": [
++ {
++ "width": 390,
++ "theme": "vibe",
++ "scheme": "dark",
++ "docW": 390,
++ "chips": [
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ }
++ ],
++ "links": 257,
++ "bg": "rgb(10, 15, 28)",
++ "fg": "rgb(230, 237, 247)",
++ "small": [
++ "Fancy text",
++ "Title case",
++ "Words and characters"
++ ],
++ "drift": "drift",
++ "categoryRows": 184,
++ "pageHeight": 17919,
++ "errors": [],
++ "consoleErrors": [
++ "Access to fetch at 'https://openvibe.network/api/themes/me/active' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED",
++ "Access to fetch at 'https://openvibe.network/api/frame?host=127.0.0.1' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED"
++ ],
++ "failed": [
++ "https://openvibe.network/api/themes/me/active — net::ERR_FAILED",
++ "https://openvibe.network/api/frame?host=127.0.0.1 — net::ERR_FAILED"
++ ]
++ },
++ {
++ "width": 390,
++ "theme": "paper",
++ "scheme": "light",
++ "docW": 390,
++ "chips": [
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ }
++ ],
++ "links": 257,
++ "bg": "rgb(250, 248, 245)",
++ "fg": "rgb(44, 36, 22)",
++ "small": [
++ "Fancy text",
++ "Title case",
++ "Words and characters"
++ ],
++ "drift": "drift",
++ "categoryRows": 184,
++ "pageHeight": 17919,
++ "errors": [],
++ "consoleErrors": [
++ "Access to fetch at 'https://openvibe.network/api/themes/me/active' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED",
++ "Access to fetch at 'https://openvibe.network/api/frame?host=127.0.0.1' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED"
++ ],
++ "failed": [
++ "https://openvibe.network/api/themes/me/active — net::ERR_FAILED",
++ "https://openvibe.network/api/frame?host=127.0.0.1 — net::ERR_FAILED"
++ ]
++ },
++ {
++ "width": 1280,
++ "theme": "vibe",
++ "scheme": "dark",
++ "docW": 1280,
++ "chips": [
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ }
++ ],
++ "links": 257,
++ "bg": "rgb(10, 15, 28)",
++ "fg": "rgb(230, 237, 247)",
++ "small": [
++ "Fancy text",
++ "Title case",
++ "Words and characters"
++ ],
++ "drift": "drift",
++ "categoryRows": 184,
++ "pageHeight": 18660,
++ "errors": [],
++ "consoleErrors": [
++ "Access to fetch at 'https://openvibe.network/api/themes/me/active' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED",
++ "Access to fetch at 'https://openvibe.network/api/frame?host=127.0.0.1' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED"
++ ],
++ "failed": [
++ "https://openvibe.network/api/themes/me/active — net::ERR_FAILED",
++ "https://openvibe.network/api/frame?host=127.0.0.1 — net::ERR_FAILED"
++ ]
++ },
++ {
++ "width": 1280,
++ "theme": "paper",
++ "scheme": "light",
++ "docW": 1280,
++ "chips": [
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ },
++ {
++ "h": 44,
++ "icon": true,
++ "underline": "none"
++ }
++ ],
++ "links": 257,
++ "bg": "rgb(250, 248, 245)",
++ "fg": "rgb(44, 36, 22)",
++ "small": [
++ "Fancy text",
++ "Title case",
++ "Words and characters"
++ ],
++ "drift": "drift",
++ "categoryRows": 184,
++ "pageHeight": 18660,
++ "errors": [],
++ "consoleErrors": [
++ "Access to fetch at 'https://openvibe.network/api/themes/me/active' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED",
++ "Access to fetch at 'https://openvibe.network/api/frame?host=127.0.0.1' from origin 'http://127.0.0.1:8794' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
++ "Failed to load resource: net::ERR_FAILED"
++ ],
++ "failed": [
++ "https://openvibe.network/api/themes/me/active — net::ERR_FAILED",
++ "https://openvibe.network/api/frame?host=127.0.0.1 — net::ERR_FAILED"
++ ]
++ }
++ ],
++ "reducedMotion": {
++ "first": "restyle your text.",
++ "second": "restyle your text.",
++ "static": true
++ }
++}
\\ No newline at end of file
ddiff --git a/docs/screenshots/tools-home/hero-1280-dark.png b/docs/screenshots/tools-home/hero-1280-dark.png
nnew file mode 100644
iindex 0000000..dc4bdb4
BBinary files /dev/null and b/docs/screenshots/tools-home/hero-1280-dark.png differ
ddiff --git a/docs/screenshots/tools-home/hero-1280-light.png b/docs/screenshots/tools-home/hero-1280-light.png
nnew file mode 100644
iindex 0000000..cd659ce
BBinary files /dev/null and b/docs/screenshots/tools-home/hero-1280-light.png differ
ddiff --git a/docs/screenshots/tools-home/hero-390-dark.png b/docs/screenshots/tools-home/hero-390-dark.png
nnew file mode 100644
iindex 0000000..d083d02
BBinary files /dev/null and b/docs/screenshots/tools-home/hero-390-dark.png differ
ddiff --git a/docs/screenshots/tools-home/hero-390-light.png b/docs/screenshots/tools-home/hero-390-light.png
nnew file mode 100644
iindex 0000000..49a28ed
BBinary files /dev/null and b/docs/screenshots/tools-home/hero-390-light.png differ
ddiff --git a/docs/tools-discovery.md b/docs/tools-discovery.md
nnew file mode 100644
iindex 0000000..51d53f2
---- /dev/null
++++ b/docs/tools-discovery.md
@@@ -0,0 +1,13 @@
++# Gateway discovery
++
++`GET /api/v1/tools/popular?limit=12` ranks successful API uses in the last 168 UTC hour buckets. `GET /api/v1/tools/trending?window=hour&limit=12` ranks positive absolute increases versus the preceding hour; `window=week` (default) compares the last 168 buckets with the preceding 168. The current hour is partial. Ties use current uses, then tool ID. Limits are 1–50.
++
++The response is `{ metric: "successful_api_runs", window, updated_at, uses_today, tools, count }`. Each tool includes its ID, name, keyword-rich description, family, icon, canonical URL and uses. Trending includes previous_uses and increase. Uses today starts at midnight UTC. Public CORS, one-minute caching, 400 for invalid parameters, 503/no-store when the database cannot answer. Empty arrays mean zero observed usage, not an error.
++
++Migration 0012 adds tool_usage_hourly. Gateway inline success records once after replay handling; successful tool jobs record in their completion transaction. Cache hits count, idempotent replays and failed/cancelled runs do not. No inputs, addresses or actor identities are stored. Browser-only transforms, legacy endpoints and jobs without a tool ID are outside this metric. No backfill is invented; totals accumulate after rollout. Rollups survive job expiry. Counts currently retain indefinitely at one row per active tool-hour.
++
++The server renders every category's tools and ItemList JSON-LD. The top strip uses real rankings then catalog choices to fill twelve slots; with no usage it says Explore tools. Kit rows, buttons and drifting strips use the existing Shared component implementations documented in OpenVibe.Design. Headline rotation pauses with its control, while hidden, and for reduced motion. Catalog task matching at /ask-tool normalises the words ("shrink a picture" → compress, image), then scores them with the catalog's own scoring: one clear winner (at least half again the runner-up) opens that tool, and anything close or nothing at all goes to the server-rendered search with the same words. It never opens a tool the words do not clearly name, and it calls no paid AI service.
++
++Launch dates in registry/discovery.js are authored from Tools PR #98, not first-seen telemetry. Add future catalog launches there. The SDK companion PR adds ov.tools.trending() and ov.tools.popular(); it can ship after the gateway API. No production migration or deployment is performed by this PR.
++
++Validation: `ov test --script test:discovery` runs the discovery, home, existing opens, run API, job usage and performance regressions (the script is `node test/run.js` with those files; the pipeline's verify runs the whole suite afterwards). The home measured 206 KB HTML (26.5 KB Brotli) with all 184 tool links and the category ItemLists, and 361.2 KB of JavaScript (86.2 KB Brotli) with the shared kit's stylesheet and behaviour file; the budgets in gateway/test/perf-budget.test.js sit just above those numbers. HTML allows space for fully populated ranking rows.
ddiff --git a/migrations/0012_tool_usage_hourly.sql b/migrations/0012_tool_usage_hourly.sql
nnew file mode 100644
iindex 0000000..58ccb87
---- /dev/null
++++ b/migrations/0012_tool_usage_hourly.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- Public discovery totals: successful runs, no inputs, addresses or actor identifiers.
++-- Kept separately from expiring jobs. UTC epoch-hour buckets; no historical backfill.
++CREATE TABLE IF NOT EXISTS tool_usage_hourly (
++ tool text NOT NULL,
++ hour bigint NOT NULL,
++ uses bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (tool, hour)
++);
++CREATE INDEX IF NOT EXISTS tool_usage_hourly_hour ON tool_usage_hourly (hour);
ddiff --git a/package.json b/package.json
iindex c844c22..9087386 100644
---- a/package.json
++++ b/package.json
@@@ -14,7 +14,8 @@
"start:yt": "npm --prefix apps/yt start",
"start:audio": "npm --prefix apps/audio start",
"start:text": "npm --prefix apps/text start",
-- "start:docs": "npm --prefix apps/docs start"
++ "start:docs": "npm --prefix apps/docs start",
++ "test:discovery": "node test/run.js gateway/test/home-jobs gateway/test/tool-opens gateway/test/discovery gateway/test/run-api _shared/test/job-usage gateway/test/perf-budget"
},
"license": "MIT",
"engines": {