openvibers/openvibe.tools Commits

Tools: the parts a reader rarely needs fold on a phone (#152)

OP OpenVibers committed 14 days ago · cd2c836c5f · 3 files, +148 −5

* Tools: the parts a reader rarely needs fold on a phone

The home was 5 screens at 390 px. The tools opened most this week, what
shipped, the questions and the rest of OpenVibe are folds now: closed on a
phone, where the title row opens them, and open where there is room, where
they look like the sections they replace. A checkbox and a label, no script,
and every part stays in the page for the crawlers. The search, the good
places to start and the job chips stay where they were. 3.2 screens at
390 px.

* Tools: the fold rules name the title row, not the part's own heading

* Tools: the shipped list keeps one title, on a phone too

Changes

ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex 94c79f5..f66fde4 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -19,6 +19,7 @@ const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/icons');
const appIcon = require('openvibe-shared/app-icon');
const registry = require('../registry');
++const { foldBlock } = require('../short');
const SITE = 'https://openvibe.tools';
const NAME = 'OpenVibe.Tools';
@@@ -94,6 +95,27 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.tile:hover,.tile:focus-visible{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,var(--bg-secondary,#111826));outline:0}
.tile>span:not(.ov-icon){min-width:0}.tile b{display:block;font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile small{display:block;font-size:12px;color:var(--text-muted,#7d8aa0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fold details{border:0;padding:0;max-width:none}.fold summary{list-style:none;display:flex;align-items:center;gap:10px}.fold summary::-webkit-details-marker{display:none}.fold summary::after{content:'+';margin-left:auto;font-size:22px;font-weight:400;color:var(--text-muted,#7d8aa0)}.fold details[open] summary::after{content:'\\2212'}.fold summary h2{display:inline}.fold details>:not(summary){margin-top:12px}
++/* The fold (the owner's short-pages rule, 2026-10-30): a part a reader rarely needs. On a phone it is closed — the
++ title row is a 44 px target that opens it — and where there is room it is open and looks like the section it
++ replaces. A checkbox and a label, no script: the part stays in the page for the crawlers. */
++.fold-block{margin-top:26px}
++.fold-toggle{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
++.fold-h{display:flex;align-items:center;gap:10px;min-height:44px;cursor:pointer}
++.fold-h>h2{margin:0;flex:1 1 auto}
++.fold-h:focus-within{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.fold-mark{flex:none}
++.fold-mark::after{content:'+';font-size:22px;font-weight:400;color:var(--text-muted,#7d8aa0)}
++.fold-toggle:checked~.fold-h .fold-mark::after{content:'\\2212'}
++.fold-body{display:none}
++.fold-toggle:checked~.fold-body{display:block}
++.fold:not(.fold-bare) .fold-body .ov-shipped-home h3{display:none}
++@media (min-width:641px){
++.fold-block{margin-top:40px}
++.fold-toggle{display:none}
++.fold-h{min-height:0;margin-bottom:6px;cursor:default}
++.fold-mark{display:none}
++.fold-body{display:block}
++}
.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}}
@@@ -227,8 +249,12 @@ ${appIcon.headTags({ site: 'tools', iconBase: '/assets' })}
<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}
--<section aria-labelledby="net-h"><div class="sec-h"><h2 id="net-h">The rest of OpenVibe</h2></div><p class="sec-p">One account works on every site. Open source and community-run.</p>
--<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>
++${foldBlock({
++ id: 'net',
++ head: '<h2 id="net-h">The rest of OpenVibe</h2>',
++ html: `<section aria-labelledby="net-h"><p class="sec-p">One account works on every site. Open source and community-run.</p>
++<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>
</body></html>`;
@@@ -381,7 +407,11 @@ ${startCard('mergepdf', (t) => `<span class="start-name">${esc(t.name)}<small>${
${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>` : ''}
++${opened.length >= 4 ? foldBlock({
++ id: 'top',
++ head: '<h2 id="top-h">Most opened this week</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));
if (!list.length) return '';
@@@ -393,12 +423,20 @@ ${jobsBlock(JOB_GROUPS.map((job) => {
-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>
--${frame.shipped({ service: 'tools', title: 'Recently shipped on OpenVibe.Tools' })}
++${foldBlock({
++ id: 'shipped',
++ head: '<h2 id="shipped-h">Recently shipped on OpenVibe.Tools</h2>',
++ html: frame.shipped({ service: 'tools', title: 'Recently shipped on OpenVibe.Tools' }),
++ })}
<section class="fold" aria-labelledby="acct-h"><details><summary><h2 id="acct-h">Better with an account, fine without</h2></summary><div class="perks">
<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>
<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>
<div><b>For developers</b><p>Run any tool from code: see <a href="/developers">the Tools API</a> (<a href="/api/v1/openapi.json">OpenAPI</a>). The catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></details></section>
--<section aria-labelledby="faq-h"><div class="sec-h"><h2 id="faq-h">Questions</h2></div>${HOME_FAQ.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</section>`;
++${foldBlock({
++ id: 'faq',
++ head: '<h2 id="faq-h">Questions</h2>',
++ html: `<section aria-labelledby="faq-h">${HOME_FAQ.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</section>`,
++ })}`;
return shell({ families, agent: true, body, head: head({
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',
ddiff --git a/apps/gateway/server/short.js b/apps/gateway/server/short.js
nnew file mode 100644
iindex 0000000..ec74c00
---- /dev/null
++++ b/apps/gateway/server/short.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ *
++ * foldBlock() a part a reader rarely needs: closed on a phone, where the title row opens it, and open where there
++ * is room, where it looks like the section it replaces. A checkbox and a label, no script: the part
++ * stays in the page for the crawlers, and a browser without CSS shows it as it was.
++ * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio inputs
++ * and :has(), no script: every block stays in the page, and a wider screen (or a browser without
++ * :has()) shows them all and no chips.
++ *
++ * The CSS for both lives in the site's own stylesheet; the rule that shows the picked block is written for the page,
++ * because there is one per block.
++ */
++const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++/** A piece of markup as a string: a plain string, or the raw html a template returned. */
++const str = (v) => (v && typeof v === 'object' && typeof v.s === 'string') ? v.s : String(v == null ? '' : v);
++
++/**
++ * A fold. The head is the heading the block had (an <h2>, so it stays a heading on a phone too); the html is what it
++ * holds. `bare` is for a part that keeps its own heading in the body: where there is room the title row gives way to
++ * it, so the part looks exactly as it did.
++ *
++ * foldBlock({ id, head, html, bare })
++ */
++function foldBlock({ id, head, html, bare = false }) {
++ const key = `fold-${esc(id)}`;
++ return `<div class="fold${bare ? ' fold-bare' : ''}" id="${key}">`
++ + `<input class="fold-toggle" type="checkbox" id="${key}-open">`
++ + `<label class="fold-h" for="${key}-open">${str(head)}<span class="fold-mark" aria-hidden="true"></span></label>`
++ + `<div class="fold-body">${str(html)}</div></div>`;
++}
++
++/**
++ * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
++ * heading gives way to the chip that names it.
++ *
++ * pickBlock({ key, label, head, panes, checked }) panes: [{ id, label, html }]; checked: the pane the first
++ * chip picks (the first one by default)
++ */
++function pickBlock({ key, label, head = '', panes, checked = '' }) {
++ if (!panes.length) return '';
++ const first = panes.find((p) => p.id === checked) ? checked : panes[0].id;
++ const chips = panes.map((p) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${p.id === first ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
++ const body = panes.map((p) => `<div class="pick-pane" id="pp-${esc(key)}-${esc(p.id)}">${str(p.html)}</div>`).join('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${body}</div></div>`;
++}
++
++/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
++function pickRule(key, id) {
++ return `.pick:has(#pk-${key}-${id}:checked) #pp-${key}-${id}{display:block}`;
++}
++
++/** The rules for every block of a pick block, in the order they appear. */
++function pickRules(key, ids) {
++ return ids.map((id) => pickRule(key, id)).join('');
++}
++
++module.exports = { foldBlock, pickBlock, pickRule, pickRules };
ddiff --git a/apps/gateway/test/home-short.test.js b/apps/gateway/test/home-short.test.js
nnew file mode 100644
iindex 0000000..6f01698
---- /dev/null
++++ b/apps/gateway/test/home-short.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — the tools
++// opened most this week, what shipped and the questions — fold closed, with a checkbox and a label and no script.
++// Everything stays in the page for the crawlers, and where there is room the fold is open and looks like the section
++// it replaces.
++const assert = require('assert');
++const 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();
++ // The folds that are on the home: the parts a reader rarely needs.
++ for (const [id, title] of [['shipped', 'Recently shipped on OpenVibe.Tools'], ['faq', 'Questions'], ['net', 'The rest of OpenVibe']]) {
++ const re = new RegExp(`<div class="fold" id="fold-${id}"><input class="fold-toggle" type="checkbox" id="fold-${id}-open">`
++ + `<label class="fold-h" for="fold-${id}-open"><h2[^>]*>[^<]*${title}[^<]*</h2>.*?<div class="fold-body">`);
++ assert.ok(re.test(home), `the ${title} fold is on the home`);
++ }
++ // The parts themselves are in the page, not removed: every question and every site in the network.
++ assert.ok((home.match(/<summary>[^<]+<\/summary>/g) || []).length >= 5, 'the questions are in the page');
++ assert.match(home, /<summary>Do I need an account\?<\/summary>/, 'the questions keep their own summaries');
++ assert.match(home, /class="net"/, 'the rest of OpenVibe is in the page');
++ assert.match(home, /data-ov-shipped="list"/, 'what shipped is in the page');
++ // What the home keeps: the search, the good places to start and the job chips.
++ 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="job-pick" role="radiogroup" aria-label="Tools by job">/, 'the job chips are where they were');
++ // The CSS: the fold is closed on a phone and open where there is room.
++ const css = home.slice(home.indexOf('/* The fold (the owner'));
++ assert.ok(css.length > 0, 'the fold rules are on the page');
++ assert.match(css, /\.fold-body\{display:none\}/, 'the body is closed by default');
++ assert.match(css, /\.fold-toggle:checked~\.fold-body\{display:block\}/, 'the title row opens it');
++ assert.match(css, /\.fold-toggle\{display:none\}/, 'the toggle is hidden where there is room');
++ assert.match(css, /\.fold-body\{display:block\}/, 'the body is open where there is room');
++ console.log('home short: all checks passed');
++ } finally {
++ srv.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });