openvibers/openvibe.tools Commits

Tools home: every job shown with its count; sideways rails on a phone instead of chips that hide the rest (#186)

OP OpenVibers committed 11 days ago · cf8ac91898 · 3 files, +42 −40

The owner's rule (2026-10-31): never hide content to shorten a page. On a phone the home showed one job at a time behind
radio chips (display:none on the other six). Now every job is shown with how many tools it has, and a job of five tools
or more is a two-row sideways rail on a phone. The Most opened and Questions folds show their count.

Changes

ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex f306883..b407f66 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -104,6 +104,7 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.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-count{margin-left:4px;font-size:13px;font-weight:650;color:var(--text-muted,#7d8aa0);font-variant-numeric:tabular-nums;vertical-align:middle}
.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}
@@@ -181,18 +182,19 @@ 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}
--.job-pick,.job-pick-h{display:none}
--@supports selector(:has(*)){@media (max-width:640px){
--.job-pick-h{display:flex;margin-top:30px}
--.job-pick{display:flex;gap:8px;overflow-x:auto;margin:10px -16px 0;padding:2px 16px 4px;scrollbar-width:none;overscroll-behavior-x:contain}.job-pick::-webkit-scrollbar{display:none}
--.job-pick input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
--.job-pick label{display:inline-flex;align-items:center;flex:none;min-height:44px;padding:0 15px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-secondary,#a8b3c4);font-size:13.5px;font-weight:650;white-space:nowrap;cursor:pointer}
--.job-pick input:checked+label{color:var(--text-primary,#e6edf7);border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}
--.job-pick input:focus-visible+label{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
--.jobs .job{display:none;padding-top:14px}.jobs .job .sec-h{display:none}
--.jobs:has(#jp-files:checked) #job-files,.jobs:has(#jp-text:checked) #job-text,.jobs:has(#jp-audio:checked) #job-audio,.jobs:has(#jp-media:checked) #job-media,.jobs:has(#jp-data:checked) #job-data,.jobs:has(#jp-web:checked) #job-web,.jobs:has(#jp-places:checked) #job-places{display:block}
--.jobs .job:target{display:block}
--}}
++.jobs-h{display:none}
++.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. */
++@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}
@@@ -332,16 +334,13 @@ const JOB_GROUPS = [
{ id: 'places', name: 'Maps and food', line: 'Find water, shelter, supplies and an affordable meal nearby.', families: ['places'] },
];
/**
-- * The job sections as one block. On a phone (640 px and under) a row of chips picks one job at a time, so the home
-- * stays short (the owner's short-pages rule, 2026-10-30): radio inputs and :has(), no script, and every section stays
-- * in the page for crawlers. A wider screen, or a browser without :has(), shows every section and no chips. A link to
-- * #job-x still opens that section (:target).
++ * 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, jobs) {
++function jobsBlock(sections) {
if (!sections.length) return '';
-- const shown = jobs.filter((j) => sections.some((h) => h.includes(`id="job-${j.id}"`)));
-- const chips = shown.map((j, i) => `<input type="radio" name="job-pick" id="jp-${j.id}" value="${j.id}"${i === 0 ? ' checked' : ''}><label for="jp-${j.id}">${esc(j.name)}</label>`).join('');
-- return `<div class="jobs"><div class="sec-h job-pick-h"><h2>Tools by job</h2><a class="more" href="/all-tools">All tools</a></div><div class="job-pick" role="radiogroup" aria-label="Tools by job">${chips}</div>${sections.join('')}</div>`;
++ 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' };
@@@ -409,15 +408,15 @@ ${startCard('dns', (t) => `<span class="start-name">${esc(t.name)}<small>${host(
</div></section>
${opened.length >= 4 ? foldBlock({
id: 'top',
-- head: '<h2 id="top-h">Most opened this week</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>`,
}) : ''}
${jobsBlock(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>`;
-- }).filter(Boolean), JOB_GROUPS)}
++ 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))}
<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' \\
@@@ -434,7 +433,7 @@ ${foldBlock({
<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>
${foldBlock({
id: 'faq',
-- head: '<h2 id="faq-h">Questions</h2>',
++ head: `<h2 id="faq-h">Questions <span class="fold-count">${HOME_FAQ.length}</span></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({
ddiff --git a/apps/gateway/test/home-jobs.test.js b/apps/gateway/test/home-jobs.test.js
iindex 1a96f7c..b4e4aab 100644
---- a/apps/gateway/test/home-jobs.test.js
++++ b/apps/gateway/test/home-jobs.test.js
@@@ -1,7 +1,7 @@
'use strict';
--// The home's job sections as one block (the owner's short-pages rule, 2026-10-30): on a phone a row of chips picks one
--// job at a time, with radio inputs and :has() and no script; every section stays in the page; a wider screen, or a
--// browser without :has(), shows them all and no chips.
++// 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');
@@@ -19,16 +19,17 @@ const site = require('../server/pages/site');
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.match(block, /<div class="job-pick" role="radiogroup" aria-label="Tools by job">/);
-- const radios = [...block.matchAll(/<input type="radio" name="job-pick" id="jp-([a-z]+)" value="\1"( checked)?><label for="jp-\1">[^<]+<\/label>/g)];
-- assert.deepStrictEqual(radios.map((m) => m[1]), sections, 'one chip per section, in the same order');
-- assert.deepStrictEqual(radios.filter((m) => m[2]).map((m) => m[1]), [sections[0]], 'the first job is picked');
-- // The CSS: the chips and their heading only under 640 px and only with :has(); one rule shows each picked section.
-- const css = home.slice(home.indexOf('.job-pick,.job-pick-h{display:none}'));
-- assert.ok(css.startsWith('.job-pick,.job-pick-h{display:none}'), 'hidden by default');
-- assert.match(css, /^\.job-pick,\.job-pick-h\{display:none\}\n@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{/);
-- for (const id of sections) assert.ok(css.includes(`.jobs:has(#jp-${id}:checked) #job-${id}`), `${id} shows when its chip is picked`);
-- assert.match(css, /\.job-pick label\{[^}]*min-height:44px/, 'a chip is a 44 px target');
++ 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();
ddiff --git a/apps/gateway/test/home-short.test.js b/apps/gateway/test/home-short.test.js
iindex 5837223..7200156 100644
---- a/apps/gateway/test/home-short.test.js
++++ b/apps/gateway/test/home-short.test.js
@@@ -19,7 +19,7 @@ const site = require('../server/pages/site');
// 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-block" 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">`);
++ + `<label class="fold-h" for="fold-${id}-open"><h2[^>]*>[^<]*${title}[^<]*(?:<span class="fold-count">\\d+</span>)?</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.
@@@ -27,10 +27,12 @@ const site = require('../server/pages/site');
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.
++ // 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="job-pick" role="radiogroup" aria-label="Tools by job">/, 'the job chips 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');
++ // 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.
const css = home.slice(home.indexOf('/* The fold (the owner'));
assert.ok(css.length > 0, 'the fold rules are on the page');