openvibers/openvibe.tools Commits

Home on phones: one job at a time, picked with chips (#151)

OP OpenVibers committed 14 days ago · ed8675c7cc · 2 files, +62 −2

The seven job sections were about 2,500 px of a 7.8-screen phone home. On a
phone (640 px and under) a row of chips picks one job at a time: 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.

Changes

ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex a10c66a..94c79f5 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -159,6 +159,18 @@ 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}
++}}
.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}
@@@ -293,6 +305,18 @@ const JOB_GROUPS = [
{ 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. 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).
++ */
++function jobsBlock(sections, jobs) {
++ 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>`;
++}
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' };
@@@ -358,12 +382,12 @@ ${startCard('compress', (t) => `<span class="start-name">${esc(t.name)}<small>${
${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>` : ''}
--${JOB_GROUPS.map((job) => {
++${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>`;
-- }).join('')}
++ }).filter(Boolean), JOB_GROUPS)}
<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' \\
ddiff --git a/apps/gateway/test/home-jobs.test.js b/apps/gateway/test/home-jobs.test.js
nnew file mode 100644
iindex 0000000..1a96f7c
---- /dev/null
++++ b/apps/gateway/test/home-jobs.test.js
@@@ -0,0 +1,36 @@
++'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.
++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.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');
++ console.log('home jobs: all checks passed');
++ } finally {
++ srv.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });