openvibers/openvibe.tools Commits

Design pass on openvibe.tools: live examples, job groups, a real API table (#134)

OP OpenVibers committed 15 days ago · 9763bdf466 · 6 files, +308 −51

* Design pass: live tool examples, job groups, API table, text tool pattern

* Tools home: the search hint stays whole on a phone

Below 640 px the hero's placeholder was cut mid-quote; keep the full one on wide screens
and a short hint on narrow ones.

* Developers page: name the SDK version this site actually pins

"tag v0.6.0+" read as a minimum the page could not back; the gateway pins v0.67.0.

* Tools home budget: 273 KB raw / 68 brotli JS

CI measured 271.5 raw (67.3 brotli) with openvibe-shared 4.27.0 — the pinned shared bundle is
larger than the 4.25.0 the old budget was set with, and the home's live demo adds 1.2 KB of its own.
The local run measured 234 KB because its node_modules still held the older shared build.

Changes

ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 5ba4d71..661da84 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -16,6 +16,43 @@
e.preventDefault(); location.href = a.getAttribute('data-go');
});
++ // The home's live example: real client-side transforms of what the visitor types. The server
++ // renders the same defaults, so the promo card still reads without this file.
++ var demo = document.getElementById('demo-text');
++ if (demo) {
++ var demoFancy = document.getElementById('demo-fancy');
++ var demoCase = document.getElementById('demo-case');
++ var demoCount = document.getElementById('demo-count');
++ function demoBold(s) {
++ return Array.from(s).map(function (ch) {
++ var u = ch.codePointAt(0);
++ if (u >= 65 && u <= 90) return String.fromCodePoint(0x1D4D0 + u - 65);
++ if (u >= 97 && u <= 122) return String.fromCodePoint(0x1D4EA + u - 97);
++ return ch;
++ }).join('');
++ }
++ function demoTitle(s) { return s.replace(/\S+/g, function (w) { return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); }); }
++ function demoCountText(s) { var w = (s.trim().match(/\S+/g) || []).length, n = Array.from(s).length; return w + ' word' + (w === 1 ? '' : 's') + ' · ' + n + ' character' + (n === 1 ? '' : 's'); }
++ function updateDemo() {
++ var v = demo.value || '';
++ if (demoFancy) demoFancy.textContent = demoBold(v);
++ if (demoCase) demoCase.textContent = demoTitle(v);
++ if (demoCount) demoCount.textContent = demoCountText(v);
++ }
++ demo.addEventListener('input', updateDemo);
++ updateDemo();
++ }
++
++ // On a phone the hero's search box shows about half the long placeholder, so it was cut mid-quote
++ // (2026-10-29 review). Keep the hint whole below 640 px; the server still renders the full one.
++ var heroInput = document.querySelector('.search input[name="q"]');
++ if (heroInput) {
++ var wideHint = heroInput.getAttribute('placeholder') || '', narrowHint = 'Try “merge pdf” or “dns lookup”';
++ var fitHint = function () { if (!heroInput.value) heroInput.setAttribute('placeholder', window.innerWidth < 640 ? narrowHint : wideHint); };
++ fitHint();
++ window.addEventListener('resize', fitHint);
++ }
++
var input = document.getElementById('q'), results = document.getElementById('results');
if (!input || !results) return;
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex dc9284e..6d7b1bc 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -103,6 +103,67 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.api-facts{list-style:none;padding:0;margin:0 0 12px;display:flex;flex-direction:column;gap:6px;color:var(--text-secondary,#a8b3c4)}.api-facts code{overflow-wrap:anywhere}
.dev{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr));gap:14px;align-items:start}.dev pre{margin:0;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}
.dev ul{margin:0;padding-left:18px;color:var(--text-secondary,#a8b3c4)}.dev li{margin:4px 0}
++.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
++.api-table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:13.5px;line-height:1.45}
++.api-table th,.api-table td{padding:10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
++.api-table th{font-size:11.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
++.api-table td code{font-size:12.5px;overflow-wrap:anywhere;word-break:break-word}
++.api-table th:nth-child(1),.api-table td:nth-child(1){width:22%}
++.api-table th:nth-child(2),.api-table td:nth-child(2){width:27%}
++.api-table th:nth-child(3),.api-table td:nth-child(3){width:15%}
++.api-table th:nth-child(4),.api-table td:nth-child(4){width:13%}
++.api-table th:nth-child(5),.api-table td:nth-child(5){width:23%}
++.api-table-empty{color:var(--text-secondary,#a8b3c4)}
++@media (max-width:700px){
++.api-table,.api-table thead,.api-table tbody,.api-table tr,.api-table td{display:block;width:auto}
++.api-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
++.api-table tr{margin:0 0 10px;padding:8px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;background:var(--bg-secondary,#111826)}
++.api-table td,.api-table td:nth-child(1),.api-table td:nth-child(2),.api-table td:nth-child(3),.api-table td:nth-child(4),.api-table td:nth-child(5){width:auto;padding:5px 0;border:0}
++.api-table td::before{content:attr(data-label);display:block;margin-bottom:2px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
++}
++/* Home: one line, one live example, then the tools grouped by the job they do. */
++.hero.hero-split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr);gap:clamp(24px,4vw,52px);align-items:center;text-align:left;padding:clamp(24px,4vw,52px) 0 12px}
++.hero-copy{min-width:0}
++.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}
++.promo-head p{margin:4px 0 14px;color:var(--text-secondary,#a8b3c4);font-size:13.5px;line-height:1.45}
++.promo label{display:block;margin:0 0 6px;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
++.promo input{width:100%;min-height:46px;padding:10px 12px;background:var(--bg-primary,#0a0f18);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;color:inherit;font:inherit}
++.promo input:focus{outline:2px solid var(--accent,#3b82f6);outline-offset:1px;border-color:transparent}
++.promo-out{margin:12px 0 0;border-top:1px solid var(--border,rgba(255,255,255,.08))}
++.promo-out>div{min-width:0;padding:9px 0;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
++.promo-out dt{margin:0;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#7d8aa0)}
++.promo-out dt a{color:inherit;text-decoration:none}
++.promo-out dt a:hover{color:var(--accent-light,var(--accent,#60a5fa))}
++.promo-out dd{margin:3px 0 0;min-height:21px;font-size:15px;line-height:1.4;overflow-wrap:anywhere}
++.promo-out output{display:block}
++.promo-foot{margin:12px 0 0;font-size:13.5px;font-weight:600}
++/* Real examples: a card carries a real input/output, not a feature chip. */
++.start-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
++.start{display:flex;flex-direction:column;gap:8px;min-width:0;padding:16px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:16px;background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}
++.start:hover,.start:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-1px);outline:0}
++.start-name{font-size:15px;font-weight:700;line-height:1.3}
++.start-name small{display:block;margin-top:2px;font-size:12px;font-weight:500;color:var(--text-muted,#7d8aa0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.start pre{margin:0;max-height:150px;padding:8px 10px;background:var(--bg-primary,#0a0f18);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:9px;font-size:12px;line-height:1.45;overflow-x:auto}
++.start-arrow{font-size:13px;line-height:1;text-align:center;color:var(--text-muted,#7d8aa0)}
++.start-note{margin:0;color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.45}
++.start-go{margin-top:auto;padding-top:2px;font-size:13.5px;font-weight:650;color:var(--accent-light,var(--accent,#60a5fa))}
++.flow{display:flex;align-items:center;flex-wrap:wrap;gap:6px;font-size:12.5px;color:var(--text-secondary,#a8b3c4)}
++.flow span{padding:4px 8px;border:1px solid var(--border,rgba(255,255,255,.1));border-radius:6px;background:var(--bg-primary,#0a0f18)}
++.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}
++.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}}
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
${icons.CSS}`;
@@@ -202,29 +263,88 @@ 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'] },
++];
++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 = []) {
// 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]));
-- const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = families.filter(f => f.path);
const withApi = require('../registry/descriptors').snapshot().tools.filter((d) => d.api && d.status !== 'unavailable').length;
// 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> · `);
-- // A family's first six: the hand-picked popular ones first, then as the registry orders them.
-- const famTop = (list) => [...list.filter((t) => POPULAR.includes(t.id)), ...list.filter((t) => !POPULAR.includes(t.id))].slice(0, 6);
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>`;
-- const body = `<div class="hero"><p class="kicker">Open source · community-run · no account needed to start</p><h1>The toolbox for <span>everything you do online</span></h1>
--<p>Convert a video, squeeze an image, merge a PDF, debug DNS, format JSON. ${tools.length} tools that open instantly, each on an address you can remember, like <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a>.</p>
++ // 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));
++
++ // The live demo and the JSON card show real transforms of real input. The bold-script
++ // transform matches the text engine's contiguous Unicode block; the JS in /js/site.js uses
++ // the same arithmetic.
++ const sample = 'OpenVibe is handy';
++ const boldText = (s) => [...s].map((ch) => {
++ const u = ch.codePointAt(0);
++ if (u >= 65 && u <= 90) return String.fromCodePoint(0x1D4D0 + u - 65);
++ if (u >= 97 && u <= 122) return String.fromCodePoint(0x1D4EA + u - 97);
++ return ch;
++ }).join('');
++ const titleText = (s) => s.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
++ const countText = (s) => { const w = (s.trim().match(/\S+/g) || []).length; const n = [...s].length; return `${w} word${w === 1 ? '' : 's'} · ${n} character${n === 1 ? '' : 's'}`; };
++ const fancy = byId.get('fancy'), caseTool = byId.get('case'), countTool = byId.get('count');
++ const demo = fancy && caseTool && countTool ? `<aside class="promo" aria-labelledby="demo-h">
++<div class="promo-head"><h2 id="demo-h">Try it now</h2><p>One line in. Three text tools answer as you type.</p></div>
++<label for="demo-text">Your text</label>
++<input id="demo-text" name="demo-text" type="text" value="${esc(sample)}" maxlength="60" autocomplete="off" spellcheck="false">
++<dl class="promo-out">
++<div><dt><a href="/tool/fancy">Fancy text</a></dt><dd><output id="demo-fancy" for="demo-text">${esc(boldText(sample))}</output></dd></div>
++<div><dt><a href="/tool/case">Title case</a></dt><dd><output id="demo-case" for="demo-text">${esc(titleText(sample))}</output></dd></div>
++<div><dt><a href="/tool/count">Words and characters</a></dt><dd><output id="demo-count" for="demo-text">${esc(countText(sample))}</output></dd></div>
++</dl>
++<p class="promo-foot"><a ${startLink(fancy)}>Open Fancy Text →</a></p>
++</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 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>
++<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('')}
--<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></div>
--<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
++<p class="hero-links">Straight to it: ${quick.join(' · ')}</p></div>
++${demo}</div>
++<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>
++<section aria-labelledby="start-h"><div class="sec-h"><h2 id="start-h">Good places to start</h2><a class="more" href="/all-tools">A to Z list</a></div>
++<p class="sec-p">Real examples, not a feature list. Each card opens the tool itself.</p>
++<div class="start-grid">
++${startCard('json', (t) => `<span class="start-name">${esc(t.name)}<small>${host(t)}</small></span><pre tabindex="0"><code>${esc(jsonIn)}</code></pre><span class="start-arrow" aria-hidden="true">↓</span><pre tabindex="0"><code>${esc(jsonOut)}</code></pre><span class="start-go">Format JSON →</span>`)}
++${startCard('mergepdf', (t) => `<span class="start-name">${esc(t.name)}<small>${host(t)}</small></span><div class="flow" aria-hidden="true"><span>PDF</span><span>PDF</span><span>PDF</span><span class="flow-arrow">→</span><span class="flow-out">one PDF</span></div><p class="start-note">Drop the files, drag them into order, download one document.</p><span class="start-go">Merge PDFs →</span>`)}
++${startCard('compress', (t) => `<span class="start-name">${esc(t.name)}<small>${host(t)}</small></span><div class="flow" aria-hidden="true"><span>PNG</span><span>JPG</span><span>WebP</span><span class="flow-arrow">→</span><span class="flow-out">smaller file</span></div><p class="start-note">Keep the pixels, lose the weight. iPhone HEIC works too.</p><span class="start-go">Compress an image →</span>`)}
++${startCard('dns', (t) => `<span class="start-name">${esc(t.name)}<small>${host(t)}</small></span><div class="flow" aria-hidden="true"><span>domain</span><span class="flow-arrow">→</span><span class="flow-out">A · AAAA · MX · TXT</span></div><p class="start-note">See what a domain actually points at, from outside your network.</p><span class="start-go">Look up DNS →</span>`)}
++</div></section>
${opened.length >= 4 ? `<section aria-labelledby="top-h"><div class="sec-h"><h2 id="top-h">Most opened this week</h2></div><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>` : ''}
--<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">Good places to start</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 6).map(toolCard).join('')}</div></section>
--${fams.map(f => { const list = tools.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div>${list.length <= 6 ? `<a class="more" href="${esc(f.path)}">${list.length === 1 ? 'About this one' : `About these ${list.length}`}</a>` : ''}</div><div class="tiles">${famTop(list).map(tile).join('')}</div>${list.length > 6 ? `<p class="fam-all"><a href="${esc(f.path)}">See all ${list.length} ${esc(f.name.toLowerCase())}</a></p>` : ''}</section>`; }).join('')}
++${JOB_GROUPS.map((job) => {
++ const list = job.families.flatMap((fid) => tools.filter((t) => t.family === fid));
++ 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></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>`;
++ }).join('')}
<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' \\
@@@ -315,8 +435,24 @@ function pageDevelopers() {
const withApi = snap.tools.filter((t) => t.api && t.status !== 'unavailable');
// A long command scrolls inside its block, not the page (it overflowed at 768 px); tabindex lets a keyboard scroll it.
const code = (s) => `<pre tabindex="0"><code>${esc(s)}</code></pre>`;
++ // The table's numbers come from the guard's real quota table, not from a copy that can drift.
++ const quotas = require('../../../_shared/guard/limits').quotas();
++ const rate = (cls, tier = 'anonymous') => { const q = quotas[cls] && quotas[cls][tier]; return q ? `${q.perMinute.toLocaleString('en-US')}/min${q.perDay ? ` · ${q.perDay.toLocaleString('en-US')}/day` : ''}` : '—'; };
++ const apiRows = [
++ ['POST /api/v1/tools/{id}/run', 'Run a tool inline; long work answers with a job', 'Anonymous; session or token for file jobs', rate('tools-run'), `curl -X POST ${SITE}/api/v1/tools/fancy/run`],
++ ['GET /api/v1/tools', 'List every tool with its inputs, limits and access', 'Public', rate('tools-api'), `curl ${SITE}/api/v1/tools?api=true`],
++ ['GET /api/v1/tools/{id}/schema', "JSON Schema for a tool's input and output", 'Public', rate('tools-api'), `curl ${SITE}/api/v1/tools/fancy/schema`],
++ ['POST /api/v1/jobs', 'Start a long job with files and follow it', 'Session or token for file jobs', rate('tools-job'), `curl -X POST ${SITE}/api/v1/jobs`],
++ ['GET /api/v1/jobs/{id}/events', 'Follow one job as server-sent events', "The job's owner", rate('tools-api'), `curl -N ${SITE}/api/v1/jobs/{id}/events`],
++ ['GET /api/v1/jobs/{id}/files/{n}', "Download a finished job's result file", "The job's owner", rate('tools-api'), `curl -O ${SITE}/api/v1/jobs/{id}/files/0`],
++ ];
const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › Developers</nav><div class="page-h">${icon('code', 56)}<h1>Tools API</h1></div>
<p class="lead">${withApi.length} of ${snap.tools.length} tools can be called from code, with the same engines the pages use. One request shape for all of them; long work runs as a job you can follow.</p>
++<section><div class="sec-h"><h2>At a glance</h2></div><p class="sec-p">The six calls behind every tool. Rate limits are the anonymous allowance; signing in or using a token raises it, and a tool's cost counts against the limit.</p>
++<table class="api-table" role="table"><caption class="sr-only">Tools API endpoints, what they do, who may call them, their anonymous rate limit and an example request</caption>
++<thead><tr role="row"><th scope="col" role="columnheader">Endpoint</th><th scope="col" role="columnheader">Purpose</th><th scope="col" role="columnheader">Auth</th><th scope="col" role="columnheader">Rate limit</th><th scope="col" role="columnheader">Example</th></tr></thead>
++<tbody>${apiRows.map((r) => `<tr role="row"><td role="cell" data-label="Endpoint"><code>${esc(r[0])}</code></td><td role="cell" data-label="Purpose">${esc(r[1])}</td><td role="cell" data-label="Auth">${esc(r[2])}</td><td role="cell" data-label="Rate limit">${esc(r[3])}</td><td role="cell" data-label="Example"><code>${esc(r[4])}</code></td></tr>`).join('')}</tbody></table>
++<p class="sec-p">Over a limit is <b>429</b> with <code>Retry-After</code>; every answer also carries <code>RateLimit-*</code> headers. Errors are RFC 9457 problems with a stable <code>code</code>.</p></section>
<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>
@@@ -334,7 +470,7 @@ const tools = createToolsClient(createClient({}));
const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });
const job = await tools.run('png', { format: 'png' }, { files: [fileBlob] });
const done = await job.wait(); // any tool, sync or job`)}
--<p class="sec-p">The SDK is <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> (tag v0.6.0+). It retries on 429 after <code>Retry-After</code> and generates idempotency keys for you.</p></section>
++<p class="sec-p">The SDK is <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> (this site pins v0.67.0). It retries on 429 after <code>Retry-After</code> and generates idempotency keys for you.</p></section>
<section><div class="sec-h"><h2>Access and limits</h2></div>
<ul><li><b>Anonymous</b>: most tools, on the lowest quota tier, per address. Tools that handle files people should not share anonymously (audio, PDF) need a browser session or a token.</li>
<li><b>Apps and services</b>: a token from <a href="https://openvibe.network">OpenVibe.Network</a> for audience <code>openvibe.tools</code> with <code>tools.tool.run</code> (developer sandbox apps get it by default) raises the tier.</li>
ddiff --git a/apps/gateway/test/perf-budget.test.js b/apps/gateway/test/perf-budget.test.js
iindex f142849..1e0fde6 100644
---- a/apps/gateway/test/perf-budget.test.js
++++ b/apps/gateway/test/perf-budget.test.js
@@@ -13,8 +13,8 @@ const BUDGETS = {
htmlRawKB: 160, // measured 135.5 (production; the catalogue is server-rendered)
htmlBrotliKB: 23, // 19.5
jsFiles: 6, // 5
-- jsRawKB: 271, // 269.3 with openvibe-shared 4.25.0 (the menu's orb beside the brand, both balances on phones, messages, the phone account sheet, the bar stepping away on phones); 263.1 with openvibe-shared 4.22.0 (the navbar's magnifier search and More; search.js and loading.js load only when used); 257.4 with openvibe-shared 4.17.0 (the folded balance chip, Across OpenVibe marks, wallet chips); 243.4 with openvibe-shared 4.15.0 (the compact footer opens into the whole one, the pulse line); 207.9 before
-- jsBrotliKB: 67.5, // 66.6 with openvibe-shared 4.25.0; 64.7 with openvibe-shared 4.22.0; 63.2 with openvibe-shared 4.17.0; 60.6 with openvibe-shared 4.15.0; 58.0 with openvibe-shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 48.5 before
++ jsRawKB: 273, // 271.5 with openvibe-shared 4.27.0 and the home's live demo (the design pass, 2026-10-29: +1.2 KB, four real transform previews); 269.3 with openvibe-shared 4.25.0 (the menu's orb beside the brand, both balances on phones, messages, the phone account sheet, the bar stepping away on phones); 263.1 with openvibe-shared 4.22.0 (the navbar's magnifier search and More; search.js and loading.js load only when used); 257.4 with openvibe-shared 4.17.0 (the folded balance chip, Across OpenVibe marks, wallet chips); 243.4 with openvibe-shared 4.15.0 (the compact footer opens into the whole one, the pulse line); 207.9 before
++ jsBrotliKB: 68, // 67.3 with openvibe-shared 4.27.0 and the live demo; 66.6 with openvibe-shared 4.25.0; 64.7 with openvibe-shared 4.22.0; 63.2 with openvibe-shared 4.17.0; 60.6 with openvibe-shared 4.15.0; 58.0 with openvibe-shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 48.5 before
cssFiles: 1, // 0
externalFiles: 3, // 1 in production
};
ddiff --git a/apps/text/public/css/shared.css b/apps/text/public/css/shared.css
iindex b946f7c..f9fa770 100644
---- a/apps/text/public/css/shared.css
++++ b/apps/text/public/css/shared.css
@@@ -1,26 +1,29 @@
/* ═══════════════════════════════════════════════════════════
Text.OpenVibe — Shared Design System
-- Matches openvibe.tools design language: dark theme, gold accent,
-- Inter font, glass-morphism cards, responsive layout
++ Matches openvibe.tools design language: one blue accent,
++ Inter font, quiet cards, responsive layout
═══════════════════════════════════════════════════════════ */
/* ── CSS Variables ─────────────────────────────────────────── */
:root {
-- --bg-primary: #0b0c12;
-- --bg-secondary: #12131c;
-- --bg-tertiary: #1a1b28;
-- --bg-card: rgba(22, 24, 36, 0.85);
-- --bg-input: rgba(15, 16, 24, 0.9);
-- --bg-hover: rgba(30, 32, 48, 0.8);
-- --accent: #c0965c;
-- --accent-light: #e1b77d;
-- --accent-dark: #9f7337;
-- --accent-glow: rgba(192, 150, 92, 0.15);
-- --text-primary: #eee;
-- --text-secondary: #999;
-- --text-muted: #666;
-- --border: rgba(255, 255, 255, 0.07);
-- --border-accent: rgba(192, 150, 92, 0.3);
++ --bg-primary: #0a0f18;
++ --bg-secondary: #111826;
++ --bg-tertiary: #172238;
++ --bg-card: rgba(17, 24, 38, 0.9);
++ --bg-input: rgba(10, 15, 24, 0.9);
++ --bg-hover: #1c2a44;
++ --accent: #3b82f6;
++ --accent-light: #60a5fa;
++ --accent-dark: #1d4ed8;
++ --accent-strong: #1d4ed8;
++ --on-accent-strong: #ffffff;
++ --accent-glow: rgba(59, 130, 246, 0.16);
++ --text-primary: #e6edf7;
++ --text-secondary: #a8b3c4;
++ --text-muted: #7d8aa0;
++ --border: rgba(255, 255, 255, 0.08);
++ --border-accent: rgba(59, 130, 246, 0.32);
++ --success: #22c55e;
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 6px;
@@@ -55,7 +58,7 @@ body {
a { color: var(--accent); text-decoration: none; transition: color var(--transition); }
a:hover { color: var(--accent-light); }
--::selection { background: rgba(192, 150, 92, 0.3); color: #fff; }
++::selection { background: rgba(59, 130, 246, 0.3); color: #fff; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; }
@@@ -235,6 +238,7 @@ textarea.large {
display: inline-flex;
align-items: center;
gap: 8px;
++ min-height: 44px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-xs);
@@@ -247,8 +251,8 @@ textarea.large {
}
.btn-primary {
-- background: linear-gradient(135deg, var(--accent), var(--accent-light));
-- color: #0b0c12;
++ background: var(--accent-strong, var(--accent-dark));
++ color: var(--on-accent-strong, #fff);
}
.btn-primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
@@@ -267,6 +271,7 @@ textarea.large {
.btn-ghost:hover { background: var(--accent-glow); }
.btn-sm { padding: 6px 14px; font-size: 0.82rem; }
++.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; filter: none; }
.btn-lg { padding: 14px 28px; font-size: 1rem; }
.btn-row {
@@@ -295,7 +300,7 @@ textarea.large {
width: 7px;
height: 7px;
border-radius: 50%;
-- background: #4ade80;
++ background: var(--success, #4ade80);
box-shadow: 0 0 6px rgba(74, 222, 128, 0.4);
animation: livePulse 2s ease-in-out infinite;
flex-shrink: 0;
@@@ -387,7 +392,7 @@ textarea.large {
.output-card:hover .copy-btn { opacity: 1; }
.output-card .copy-btn:hover { color: var(--accent); border-color: var(--accent); }
--.output-card .copy-btn.copied { color: #4ade80; border-color: #4ade80; }
++.output-card .copy-btn.copied { color: var(--success, #4ade80); border-color: var(--success, #4ade80); }
/* ── Single output (non-grid) ──────────────────────────────── */
.output-box {
@@@ -720,6 +725,36 @@ textarea.large {
margin-bottom: 12px;
}
++/* ── Focused tool page (fancy is the pattern) ──────────────── */
++.tool-wrap .crumbs { padding: 4px 0 8px; font-size: 0.82rem; color: var(--text-muted); }
++.tool-wrap .crumbs a { color: inherit; }
++.tool-head { padding: 4px 0 20px; }
++.tool-head h1 { font-size: clamp(1.6rem, 3vw, 2.1rem); line-height: 1.15; letter-spacing: -0.02em; color: var(--text-primary); }
++.tool-head .subtitle { margin-top: 6px; color: var(--text-secondary); font-size: 1rem; max-width: 62ch; }
++.field-label { display: block; margin-bottom: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); }
++.primary-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
++.primary-row .btn-primary { min-height: 48px; font-size: 0.98rem; }
++.char-count { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-muted); }
++.results-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin: 26px 0 6px; }
++.results-head h2 { font-size: 1.15rem; margin: 0; }
++.results-head p { margin: 0; color: var(--text-muted); font-size: 0.85rem; }
++.tool-options, .api-note { margin-top: 16px; padding: 0 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
++.tool-options summary, .api-note summary { display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 10px 0; font-weight: 650; cursor: pointer; list-style: none; }
++.tool-options summary::-webkit-details-marker, .api-note summary::-webkit-details-marker { display: none; }
++.tool-options summary::after, .api-note summary::after { content: '+'; margin-left: auto; font-size: 1.25rem; font-weight: 400; color: var(--text-muted); }
++.tool-options[open] summary::after, .api-note[open] summary::after { content: '−'; }
++.opt-grid { display: flex; flex-direction: column; gap: 2px; padding: 0 0 10px; }
++.check { display: flex; align-items: center; gap: 10px; min-height: 44px; font-size: 0.92rem; color: var(--text-secondary); cursor: pointer; }
++.check input { width: 20px; height: 20px; flex: none; accent-color: var(--accent); }
++.hide-labels .style-name { display: none; }
++.api-note { padding-bottom: 14px; }
++.api-note p { margin: 0 0 10px; color: var(--text-secondary); font-size: 0.92rem; }
++.api-note pre { margin: 0 0 10px; padding: 12px 14px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; overflow-x: auto; white-space: pre; }
++.api-note code { font-family: var(--font-mono); font-size: 0.86em; }
++.copy-request { margin: 0 0 12px; }
++.noscript-note { margin: 10px 0 0; color: var(--text-secondary); font-size: 0.9rem; }
++@media (max-width: 560px) { .primary-row .btn-primary { width: 100%; justify-content: center; } }
++
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 768px) {
.page-header { padding: 32px 0 20px; }
@@@ -823,7 +858,7 @@ textarea.large {
/* ── Interconnect banner ───────────────────────────────────── */
.interconnect-banner {
-- background: linear-gradient(135deg, rgba(192, 150, 92, 0.08), rgba(192, 150, 92, 0.02));
++ background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(59, 130, 246, 0.02));
border: 1px solid var(--border-accent);
border-radius: var(--radius);
padding: 20px 28px;
ddiff --git a/apps/text/public/fancy.html b/apps/text/public/fancy.html
iindex 4ef246b..4fc202f 100644
---- a/apps/text/public/fancy.html
++++ b/apps/text/public/fancy.html
@@@ -24,26 +24,63 @@
</head>
<body>
<div class="tool-wrap">
-- <div class="page-header">
-- <h1><i class="fas fa-wand-magic-sparkles"></i> Fancy Text Generator</h1>
-- <p class="subtitle">Type or paste your text and instantly see it in 30+ Unicode font styles. Click any to copy.</p>
-- </div>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="https://text.openvibe.tools/">Text</a> › Fancy Text</nav>
++ <header class="tool-head">
++ <h1>Fancy Text Generator</h1>
++ <p class="subtitle">Type or paste your text and see it in 30+ Unicode styles below. Copy any style, or copy them all.</p>
++ </header>
-- <div class="tool-section">
++ <main>
++ <section class="tool-section">
++ <label for="input" class="field-label">Your text</label>
<div class="input-group">
-- <textarea id="input" class="large" placeholder="Type your text here..." autofocus>Hello World</textarea>
++ <textarea id="input" class="large" placeholder="Type your text here..." maxlength="2000">Hello World</textarea>
</div>
-- <div class="live-hint"><span class="live-dot"></span> Results update as you type</div>
-- <div class="generate-row">
-- <button class="btn btn-primary generate-btn" onclick="document.querySelector('#input, textarea').dispatchEvent(new Event('input', {bubbles:true}))"><i class="fas fa-bolt"></i> Generate</button>
++ <div class="live-hint"><span class="live-dot" aria-hidden="true"></span> Results update as you type</div>
++ <p class="char-count" id="char-count">11 / 2,000 characters</p>
++ <div class="primary-row">
++ <button class="btn btn-primary" id="copy-all" type="button">Copy all 31 styles</button>
++ <button class="btn btn-ghost" id="clear" type="button">Clear</button>
</div>
-- <div class="btn-row">
-- <button class="btn btn-ghost btn-sm" onclick="document.getElementById('input').value=''; updateAll()"><i class="fas fa-xmark"></i> Clear</button>
-- <button class="btn btn-ghost btn-sm" onclick="OpenVibeUI.copyText(getAllText())"><i class="fas fa-copy"></i> Copy All</button>
++ </section>
++
++ <section aria-labelledby="results-h">
++ <div class="results-head"><h2 id="results-h">Results</h2><p>Copy any style below, or copy them all at once.</p></div>
++ <div id="output" class="output-grid"></div>
++ <noscript>
++ <p class="noscript-note">JavaScript is off, so the live preview cannot run. Six of the 31 styles for “Hello World”:</p>
++ <div class="output-grid">
++ <div class="output-card"><div class="style-name">Bold</div><div class="style-text">𝐇𝐞𝐥𝐥𝐨 𝐖𝐨𝐫𝐥𝐝</div></div>
++ <div class="output-card"><div class="style-name">Italic</div><div class="style-text">𝐻𝑒𝑙𝑙𝑜 𝑊𝑜𝑟𝑙𝑑</div></div>
++ <div class="output-card"><div class="style-name">Script</div><div class="style-text">ℋℯ𝓁𝓁ℴ 𝒲ℴ𝓇𝓁𝒹</div></div>
++ <div class="output-card"><div class="style-name">Double-Struck</div><div class="style-text">ℍ𝕖𝕝𝕝𝕠 𝕎𝕠𝕣𝕝𝕕</div></div>
++ <div class="output-card"><div class="style-name">Small Caps</div><div class="style-text">Hᴇʟʟᴏ Wᴏʀʟᴅ</div></div>
++ <div class="output-card"><div class="style-name">Monospace</div><div class="style-text">𝙷𝚎𝚕𝚕𝚘 𝚆𝚘𝚛𝚕𝚍</div></div>
++ </div>
++ <p class="noscript-note">The full generator needs JavaScript; the <a href="https://openvibe.tools/tool/fancy">tool page</a> and the API note below still work.</p>
++ </noscript>
++ </section>
++
++ <details class="tool-options">
++ <summary>Options</summary>
++ <div class="opt-grid">
++ <label class="check"><input type="checkbox" id="opt-names" checked> Include style names when copying all</label>
++ <label class="check"><input type="checkbox" id="opt-labels" checked> Show style names on the results</label>
</div>
-- </div>
++ </details>
++
++ <details class="api-note">
++ <summary>Use it from code</summary>
++ <p>One POST request runs the same engine as this page. There is no MCP server yet; the HTTP API is the way to script it.</p>
++ <pre tabindex="0"><code id="curl-code">curl -X POST https://openvibe.tools/api/v1/tools/fancy/run \
++ -H 'Content-Type: application/json' \
++ -d '{"input":{"text":"Hello World"}}'
-- <div id="output" class="output-grid"></div>
++# → {"state":"succeeded","tool":"fancy","result":{"data":{"styles":[...]}}}</code></pre>
++ <button class="btn btn-secondary btn-sm copy-request" type="button" id="copy-curl">Copy request</button>
++ <p>Inputs and output are described at <a href="https://openvibe.tools/api/v1/tools/fancy/schema">/api/v1/tools/fancy/schema</a>. <a href="https://openvibe.tools/developers">How the API works</a>.</p>
++ </details>
++ </main>
<div id="ov-footer" data-related="Zalgo Text|https://zalgo.openvibe.tools;ASCII Art|https://ascii.openvibe.tools;Case Converter|https://case.openvibe.tools"></div>
</div>
@@@ -58,10 +95,17 @@
OpenVibeUI.initPage();
const input = document.getElementById('input');
const output = document.getElementById('output');
++ const copyAll = document.getElementById('copy-all');
++ const charCount = document.getElementById('char-count');
++ const optNames = document.getElementById('opt-names');
++ const optLabels = document.getElementById('opt-labels');
const cards = {};
function updateAll() {
const text = input.value || '';
++ const n = Array.from(text).length;
++ charCount.textContent = n.toLocaleString('en-US') + ' / 2,000 characters';
++ copyAll.disabled = !text.trim();
if (!text.trim()) {
output.innerHTML = '<div style="color: var(--text-muted); text-align: center; grid-column: 1/-1; padding: 40px;">Start typing to see your text in fancy Unicode fonts...</div>';
return;
@@@ -77,14 +121,19 @@
if (cards[key]) OpenVibeUI.updateOutputCard(cards[key], r.text);
}
}
++ copyAll.textContent = 'Copy all ' + Object.keys(results).length + ' styles';
}
function getAllText() {
const text = input.value || '';
const results = TextOpenVibeEngine.transformAll(text);
-- return Object.entries(results).map(([k, r]) => `${r.name}:\n${r.text}`).join('\n\n');
++ return Object.entries(results).map(([k, r]) => optNames.checked ? `${r.name}:\n${r.text}` : r.text).join('\n\n');
}
++ copyAll.addEventListener('click', function () { OpenVibeUI.copyText(getAllText(), copyAll); });
++ document.getElementById('clear').addEventListener('click', function () { input.value = ''; updateAll(); input.focus(); });
++ optLabels.addEventListener('change', function () { output.classList.toggle('hide-labels', !optLabels.checked); });
++ document.getElementById('copy-curl').addEventListener('click', function () { OpenVibeUI.copyText(document.getElementById('curl-code').textContent, this); });
input.addEventListener('input', updateAll);
updateAll();
</script>
ddiff --git a/apps/text/public/js/shared.js b/apps/text/public/js/shared.js
iindex 9c01a0f..1dd5bcc 100644
---- a/apps/text/public/js/shared.js
++++ b/apps/text/public/js/shared.js
@@@ -71,7 +71,7 @@ function createOutputCard(name, text, container) {
card.innerHTML = `
<div class="style-name">${escapeHtml(name)}</div>
<div class="style-text">${escapeHtml(text)}</div>
-- <button class="copy-btn" title="Copy">Copy</button>
++ <button class="copy-btn" title="Copy ${escapeHtml(name)}" aria-label="Copy ${escapeHtml(name)} style">Copy</button>
`;
card.querySelector('.copy-btn').addEventListener('click', (e) => {
e.stopPropagation();