openvibers/openvibe.tools Commits
The home shows live use, every tool in one click, and the discovery API (#254)
Changes
@@@ -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();@@@ -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 countedassert.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 });nnew file mode 100644@@@ -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 };@@@ -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);}@@@ -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();++})();@@@ -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 SPAapp.use((req, res, next) => {@@@ -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 }] }' } } } };@@@ -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 — thetitle 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))); });nnew file mode 100644@@@ -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 };@@@ -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;@@@ -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>`;}nnew file mode 100644@@@ -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); });@@@ -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, '&')}"`), 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');@@@ -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.@@@ -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 ItemListsjsFiles: 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 stylesheetexternalFiles: 3, // 1 in production};@@@ -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));nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/390-light.png differnnew file mode 100644@@@ -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 filennew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/hero-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/hero-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/hero-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/tools-home/hero-390-light.png differnnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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);@@@ -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": {
@@ -130,6 +130,11 @@ function createJobSystem(o) {
@@ -130,6 +130,11 @@ function createJobSystem(o) {
const row = await store.get(id);
const row = await store.get(id);
await announce.finished(t, row);
await announce.finished(t, row);
await usage.finished(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();
const types = new Map();
@@ -101,6 +101,7 @@ function define(system) {
@@ -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');
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, env: 'sandbox' }, 'succeeded');
await run({ project: PRJ, tool: 'image-resize' }, '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
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');
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 });
const { job: held } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { hold: true }, project: PRJ });
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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 };
@@ -195,6 +195,7 @@ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
@@ -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 {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,
* @param {object} [o.jobs] a satellite's jobs: { system: () => system, receive: (d) => middleware,
* fetchSibling: (ref, req) → file | null }
* fetchSibling: (ref, req) → file | null }
+ * @param {(tool: string) => Promise<void>} [o.onSuccess] optional inline success counter; replays bypass it
* @param {object} [o.log]
* @param {object} [o.log]
*/
*/
function createRunApi(o) {
function createRunApi(o) {
@@ -309,6 +310,9 @@ function createRunApi(o) {
@@ -309,6 +310,9 @@ function createRunApi(o) {
}
}
}
}
if (replayKey) replays.set(replayKey, { hash: reqHash, body }, REPLAY_TTL_MS);
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);
return send(res, 200, body);
}
}
@@ -169,3 +169,31 @@
@@ -169,3 +169,31 @@
}).catch(function () { /* optional */ });
}).catch(function () { /* optional */ });
var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
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();
+})();
@@ -228,6 +228,8 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req
@@ -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.
// 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 });
const actorLimits = require('../../_shared/actor-limits').createToolsLimits({ app: 'gateway', createActorLimiter: require('openvibe-sdk/limits').createActorLimiter, guard, registry: obs.registry });
app.use(actorLimits.registryReads);
app.use(actorLimits.registryReads);
+const discovery = require('./registry/discovery').createDiscovery({ db: toolsDb.db, registry });
+app.use(discovery.router());
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
// 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');
@@ -292,6 +294,7 @@ const { createJobsFacade, createJobIndex } = require('./run/jobs-facade');
const { ajvFrom } = require('../../_shared/tools/run');
const { ajvFrom } = require('../../_shared/tools/run');
const jobIndex = createJobIndex();
const jobIndex = createJobIndex();
const runApi = createGatewayRun({
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 },
guard, contracts: require('openvibe-contracts'), toolRegistry, routers: { net: netRouter, dev: devRouter },
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
ports: () => SATELLITES, parseJson: jsonBody, jobIndex, ...ajvFrom(require),
});
});
@@ -461,7 +464,7 @@ app.use(require('./codes/image').router());
@@ -461,7 +464,7 @@ app.use(require('./codes/image').router());
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
// 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
// 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.
// 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
// Net tool subdomains → net.html SPA
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -56,6 +56,21 @@ function openapi(snapshot, site) {
@@ -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}/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 } } },
'/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.
// 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 });
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 }] }' } } } };
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 }] }' } } } };
@@ -17,6 +17,7 @@ const social = require('openvibe-shared/social');
@@ -17,6 +17,7 @@ const social = require('openvibe-shared/social');
const express = require('express');
const express = require('express');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
const icons = require('openvibe-shared/icons');
+const kit = require('openvibe-shared/kit');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const registry = require('../registry');
const registry = require('../registry');
const { seoFor } = require('../seo/catalog');
const { seoFor } = require('../seo/catalog');
@@ -42,6 +43,10 @@ function toolCard(t) {
@@ -42,6 +43,10 @@ function toolCard(t) {
}
}
const CSS = `
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%}
*{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}
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))}
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
@@ -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{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}
.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}
.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}
.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
/* 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
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
@@ -117,6 +118,17 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.fold-body{display:none}
.fold-body{display:none}
.fold-toggle:checked~.fold-body{display:block}
.fold-toggle:checked~.fold-body{display:block}
.fold-block:not(.fold-bare) .fold-body .ov-shipped-home h3{display:none}
.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){
@media (min-width:641px){
.fold-block{margin-top:40px}
.fold-block{margin-top:40px}
.fold-toggle{display:none}
.fold-toggle{display:none}
@@ -126,7 +138,7 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
@@ -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}
.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}
.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}
#ov-footer{margin-top:56px}
.tool .opens{color:var(--text-secondary,#a8b3c4);font-weight:600}
.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}
.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
@@ -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 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-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-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}
.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{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}
.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
@@ -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-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)}
.flow .flow-arrow{border:0;background:none;padding:0;color:var(--text-muted,#7d8aa0)}
.job{padding:clamp(24px,4vw,36px) 0 0}
.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}
.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){
@media (max-width:640px){
-.jobs-h{display:flex;margin-top:30px}
.jobs .job{padding-top:22px}
.jobs .job{padding-top:22px}
.jobs .job .sec-p{margin:2px 0 0;font-size:13.5px}
.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 .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: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}}
@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
/* 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}
@@ -218,7 +220,6 @@ ${icons.CSS}
main.wrap summary, .fold summary { min-height: 44px; }
main.wrap summary, .fold summary { min-height: 44px; }
.sec-h a.more { position: relative; display: inline-block; }
.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; }
.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-out dt a { display: inline-block; min-height: 44px; }
.promo-foot a { position: relative; display: inline-block; }
.promo-foot a { position: relative; display: inline-block; }
.promo-foot a::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
.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' })}
@@ -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="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">
<link rel="preconnect" href="https://openvibe.network">
<link rel="preconnect" href="https://openvibe.network">
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
<style>${CSS}</style></head><body>${spriteSvg}
<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>
<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}
<main class="wrap">${body}
@@ -265,7 +267,7 @@ ${foldBlock({
@@ -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>`,
<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' })}
</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>`;
</body></html>`;
}
}
@@ -275,13 +277,15 @@ function head(o) {
@@ -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));
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) {
function score(t, terms) {
let s = 0; const name = t.name.toLowerCase(); const hay = [t.tagline, t.description, ...(t.keywords || []), t.id].join(' ').toLowerCase();
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; }
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;
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) {
function search(q) {
const terms = String(q || '').toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
const terms = String(q || '').toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
if (!terms.length) return [];
if (!terms.length) return [];
@@ -382,30 +386,7 @@ const HOME_FAQ = [
@@ -382,30 +386,7 @@ const HOME_FAQ = [
];
];
// ── Pages ────────────────────────────────────────────────────
// ── 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").
// Only built tools: a home never shows what is planned (plan §1 "no placeholders").
const { tools, families } = registry.get();
const { tools, families } = registry.get();
const byId = new Map(tools.map(t => [t.id, t]));
const byId = new Map(tools.map(t => [t.id, t]));
@@ -414,9 +395,6 @@ function pageIndex(top = []) {
@@ -414,9 +395,6 @@ function pageIndex(top = []) {
// Most opened this week (registry/opens.js): real counts, shown once at least four tools have some.
// 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 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 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 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 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));
const host = (t) => esc(hostLabel(t.hosts.short || t.hosts.canonical));
@@ -447,14 +425,30 @@ function pageIndex(top = []) {
@@ -447,14 +425,30 @@ function pageIndex(top = []) {
</aside>` : '';
</aside>` : '';
const jsonIn = '{"b":1,"a":2}';
const jsonIn = '{"b":1,"a":2}';
const jsonOut = JSON.stringify(JSON.parse(jsonIn), null, 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">
const body = `<div class="hero hero-split">
<div class="hero-copy"><p class="kicker">Open source · community-run · no account needed to start</p>
<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>
<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('')}
${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>
${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>
<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="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>
<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({
@@ -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>`,
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>`,
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 '';
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>
<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 \\
<div class="dev"><pre tabindex="0"><code>curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
-H 'Content-Type: application/json' \\
-H 'Content-Type: application/json' \\
-d '{"input":{"text":"{ \\"a\\": 1 }"}}'</code></pre>
-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({
${foldBlock({
id: 'shipped',
id: 'shipped',
head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Tools</h2>',
head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Tools</h2>',
@@ -500,7 +499,7 @@ ${foldBlock({
@@ -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.`,
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',
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}' }),
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 })))] }) });
seo.jsonLd.faq(HOME_FAQ.map(([q, a]) => ({ q, a })))] }) });
}
}
@@ -607,7 +606,8 @@ function pageDevelopers() {
@@ -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>
<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>
<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><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>
<section><div class="sec-h"><h2>Run a tool</h2></div>
${code(`curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
${code(`curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
-H 'Content-Type: application/json' \\
-H 'Content-Type: application/json' \\
@@ -639,7 +639,7 @@ function send(req, res, key, render, type, extra) {
@@ -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);
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('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);
res.set('ETag', e.etag);
if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
res.send(e.html);
res.send(e.html);
@@ -700,7 +700,7 @@ function llmsFullBody() {
@@ -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 })) })) });
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();
const router = express.Router();
// One canonical address per tool: its primary host. The directory's /tool/<id> page is only a pointer, so it
// 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
// 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 } = {}) {
@@ -736,7 +736,29 @@ function createSiteRouter({ opens = null } = {}) {
// fails leaves that section out, never the page.
// fails leaves that section out, never the page.
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const top = opens ? await opens.top(7, 12).catch(() => []) : [];
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('/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))); });
router.get('/search', (req, res) => { res.set('Cache-Control', 'no-store'); res.type('html').send(pageSearch(String(req.query.q || '').slice(0, 80))); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
@@ -145,6 +145,7 @@ function createGatewayRun(o) {
@@ -145,6 +145,7 @@ function createGatewayRun(o) {
snapshot: () => o.toolRegistry.snapshot(),
snapshot: () => o.toolRegistry.snapshot(),
validate: createInputValidator(o.Ajv, o.addFormats),
validate: createInputValidator(o.Ajv, o.addFormats),
parseJson: o.parseJson,
parseJson: o.parseJson,
+ onSuccess: o.onSuccess,
runs(d) {
runs(d) {
const k = kind(d);
const k = kind(d);
return k === 'engine' || k === 'route' ? 'inline' : null;
return k === 'engine' || k === 'route' ? 'inline' : null;
@@ -37,13 +37,13 @@ function foldMeta(body, cls = '') {
@@ -37,13 +37,13 @@ function foldMeta(body, cls = '') {
return text ? `<span${cls ? ` class="${cls}"` : ''}>${text}</span>` : '';
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)}`;
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
// 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.
// developer block), and the two must not share their rules.
return `<div class="fold-block${bare ? ' fold-bare' : ''}" id="${key}">`
return `<div class="fold-block${bare ? ' fold-bare' : ''}" id="${key}">`
+ `<input class="fold-toggle" type="checkbox" id="${key}-open">`
+ `<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>`;
+ `<div class="fold-body">${str(html)}</div></div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -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); });
@@ -1,37 +1,21 @@
@@ -1,37 +1,21 @@
'use strict';
'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 assert = require('assert');
-const http = require('http');
-const express = require('express');
const site = require('../server/pages/site');
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, '&')}"`), 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');
@@ -30,7 +30,7 @@ const site = require('../server/pages/site');
@@ -30,7 +30,7 @@ const site = require('../server/pages/site');
// What the home keeps: the search, the good places to start and the jobs.
// 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, /<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, /<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).
// 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');
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.
// The CSS: the fold is closed on a phone and open where there is room.
@@ -10,12 +10,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -10,12 +10,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const { startApp } = require('../../_shared/test/spawn');
const { startApp } = require('../../_shared/test/spawn');
const BUDGETS = {
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
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
externalFiles: 3, // 1 in production
};
};
@@ -73,6 +73,10 @@ function checkProblem(r, status, code, where) {
@@ -73,6 +73,10 @@ function checkProblem(r, status, code, where) {
let r = await run('jsonminify', { input: { text: '{ "a": [1, 2] }' } });
let r = await run('jsonminify', { input: { text: '{ "a": [1, 2] }' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
checkRun(r.body, 'jsonminify');
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.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.strictEqual(r.h.get('cache-control'), 'no-store');
assert.ok(Number.isInteger(r.body.took_ms));
assert.ok(Number.isInteger(r.body.took_ms));
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/1280-light.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/390-dark.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/390-light.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,371 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/hero-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/hero-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/hero-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/hero-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/hero-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/hero-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/tools-home/hero-390-light.png differ
Binary files /dev/null and b/docs/screenshots/tools-home/hero-390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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);
@@ -14,7 +14,8 @@
@@ -14,7 +14,8 @@
"start:yt": "npm --prefix apps/yt start",
"start:yt": "npm --prefix apps/yt start",
"start:audio": "npm --prefix apps/audio start",
"start:audio": "npm --prefix apps/audio start",
"start:text": "npm --prefix apps/text 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",
"license": "MIT",
"engines": {
"engines": {