openvibers/openvibe.tools Commits
The home's headline: each phrase is a link to its tool, and the swap is animated (#270)
Changes
@@@ -170,29 +170,38 @@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.++// The headline's phrases are links already in the page; this shows them in turn, once through, then rests on the++// first. The button pauses, or plays it again. Reduced motion keeps the first. Never an aria-live region.(function () {const el = document.getElementById('tool-headline');-- if (!el) return;++ const items = el ? Array.from(el.querySelectorAll('.hm-ph')) : [];++ if (items.length < 2) 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');++ let i = 0, timer = 0, paused = false, rested = false;++ const show = (n) => {++ const from = items[i], to = items[n];++ if (from === to) return;++ from.classList.add('is-out'); from.classList.remove('is-on'); from.setAttribute('aria-hidden', 'true'); from.tabIndex = -1;++ setTimeout(() => from.classList.remove('is-out'), 500);++ to.classList.add('is-on'); to.removeAttribute('aria-hidden'); to.removeAttribute('tabindex');++ i = n;++ };const update = () => {-- clearInterval(timer);++ clearInterval(timer); timer = 0;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 (motion.matches) { show(0); return; }++ if (!paused && !rested && !document.hidden) timer = setInterval(() => {++ if (el.matches(':hover') || el.contains(document.activeElement)) return;++ const next = (i + 1) % items.length;++ show(next);++ if (next === 0) { rested = true; update(); }++ }, 3200);++ if (!control) return;++ const say = paused || rested ? 'Play the headline' : 'Pause the headline';++ control.setAttribute('aria-label', say); control.title = say; control.classList.toggle('is-paused', paused || rested);};-- 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();-- });++ if (control) control.addEventListener('click', () => { if (rested) rested = paused = false; else paused = !paused; update(); });motion.addEventListener('change', update);document.addEventListener('visibilitychange', update);update();@@@ -121,6 +121,15 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px/* 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 last words change: each phrase is a link to its tool, all stacked in one cell (as tall as the++ tallest, so nothing below moves). The one showing rises in; the one leaving lifts away. Once through, then it rests. */++.hm-rot{display:grid}.hm-ph{grid-area:1/1;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;opacity:0;visibility:hidden;transform:translateY(.35em);++transition:opacity var(--ov-dur-3,.32s) var(--ov-ease-out,ease-out),transform var(--ov-dur-3,.32s) var(--ov-ease-out,ease-out),visibility 0s linear var(--ov-dur-3,.32s)}++.hm-ph.is-on{opacity:1;visibility:visible;transform:none;transition-delay:0s}.hm-ph.is-out{transform:translateY(-.35em)}++.hm-ph:hover,.hm-ph:focus-visible{text-decoration:underline;text-decoration-thickness:.06em;text-underline-offset:.12em}++@media (prefers-reduced-motion:reduce){.hm-ph{transition:none;transform:none}}++/* The strip of tools drifts, so its ends fade: a chip cut by a hard edge looked broken. */++.quick-strip{-webkit-mask-image:linear-gradient(90deg,transparent,#000 22px,#000 calc(100% - 22px),transparent);mask-image:linear-gradient(90deg,transparent,#000 22px,#000 calc(100% - 22px),transparent);padding-inline:14px;margin-inline:-14px;max-width:calc(100% + 28px)}/* 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;@@@ -430,8 +439,21 @@ function pageIndex(top = [], activity = { available: false }) {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';++ // A tool with no hand-written phrase speaks in its own tagline: its first clause, as the end of "Small tools++ // that …" ("Look up DNS records for any domain: A, AAAA…" → "look up DNS records for any domain"). An acronym++ // keeps its capitals. "help with <name>" was the old fallback, and read like a form letter.++ const clause = (t) => {++ let s = String(t.tagline || '').split(/[:;.(]|, | — | - /)[0].trim();++ if (s.split(/\s+/).length < 2 || s.length > 46) return null;++ if (/^[A-Z][a-z]/.test(s)) s = s[0].toLowerCase() + s.slice(1);++ return s;++ };++ const spoken = quickTools.map(t => [t, phrases[t.id] || clause(t)]).filter(([, p]) => p).slice(0, 6);++ // Every phrase is in the page as a link to its tool, stacked in one grid cell: the cell is as tall as the tallest,++ // so the swap never moves the page, and each tool is a plain link a crawler reads. One shows at a time.++ const headlineHtml = spoken.length++ ? `<span class="hm-rot" id="tool-headline">${spoken.map(([t, p], i) => `<a class="hm-ph${i ? '' : ' is-on'}" href="${esc(t.url)}"${go(t)}${i ? ' aria-hidden="true" tabindex="-1"' : ''}>${esc(p)}.</a>`).join('')}</span>`++ : '<span class="hm-rot">finish the job.</span>';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>`;@@@ -442,7 +464,7 @@ ${rankedSection('Trending this week', activity.week, { trend: true, note: 'Risin${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>--<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>++<div class="hm-title"><h1>Small tools that ${headlineHtml}</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('')}<nav class="quick-strip" data-ovk-scroll="drift" aria-label="${rankedIds.length ? 'Popular tools' : 'Explore tools'}">${quick.join('')}</nav></div>nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++// The home's headline, "Small tools that …" (the owner, 2026-11-09: slogans that "animate between" the many tools).++// Its last words change, and each phrase is a real link to its tool, already in the page: stacked in one grid cell++// so the swap never moves what is below, and readable by a crawler as plain links. The old one swapped bare text from++// a data attribute, linked nowhere, looped for ever and fell back to "help with <tool name>".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++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));++ try {++ const home = await (await fetch(`http://127.0.0.1:${srv.address().port}/`)).text();++ const rot = home.match(/<h1>Small tools that <span class="hm-rot" id="tool-headline">(.*?)<\/span><\/h1>/s);++ assert.ok(rot, 'the headline ends in the rotating phrases');++ const links = Array.from(rot[1].matchAll(/<a class="hm-ph( is-on)?" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g));++ assert.ok(links.length >= 2 && links.length <= 6, `two to six phrases (${links.length})`);++ assert.strictEqual(links.filter((m) => m[1]).length, 1, 'one phrase shows');++ assert.ok(links[0][1], 'and it is the first: the page is whole without script');++ links.forEach((m, i) => {++ assert.match(m[2], /^(https:\/\/[a-z0-9.-]+\/|\/tool\/[a-z0-9-]+)/, `phrase ${i} links to a tool`);++ assert.match(m[4], /^[a-z][^<]{3,48}\.$/, `phrase ${i} reads as the end of the sentence: "${m[4]}"`);++ assert.ok(!/^help with /.test(m[4]), 'no form-letter fallback');++ if (i) assert.ok(/aria-hidden="true"/.test(m[3]) && /tabindex="-1"/.test(m[3]), 'a phrase that is not showing is out of the tab order and unread');++ });++ assert.strictEqual(new Set(links.map((m) => m[2])).size, links.length, 'each phrase is a different tool');++ assert.ok(!home.includes('data-phrases='), 'the phrases are links in the page, not a data attribute');++++ // The swap is CSS on shared timing, still under reduced motion; the script goes once through, then rests.++ assert.match(home, /\.hm-rot\{display:grid\}\.hm-ph\{grid-area:1\/1;/, 'the phrases share one cell, so nothing below moves');++ assert.match(home, /@media \(prefers-reduced-motion:reduce\)\{\.hm-ph\{transition:none;transform:none\}\}/);++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'site.js'), 'utf8');++ assert.ok(js.includes('if (next === 0) { rested = true;'), 'once through, then it rests');++ assert.ok(js.includes("el.matches(':hover') || el.contains(document.activeElement)"), 'it never changes under a pointer or the focus');++ console.log(`home-headline: ${links.length} phrases, each a link to its tool; one shows; once through, then still`);++ } finally {++ srv.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const BUDGETS = {htmlRawKB: 225, // 206 measured with all 184 crawlable tool rows; room for populated ranking rowshtmlBrotliKB: 29, // 26.5 measured with the complete category ItemListsjsFiles: 6, // 5-- jsRawKB: 365, // 361.2: the Frame plus the kit's behaviour file (kit-web.js) for the drifting strip, 2026-11-09++ jsRawKB: 366, // 365.5: the headline's phrases as links with an animated swap (+0.6), 2026-11-10; 361.2 on 11-09 plus the Frame's growth sincejsBrotliKB: 88, // 86.2 with openvibe-shared 4.163.0, 2026-11-09cssFiles: 1, // 1: the kit's stylesheetexternalFiles: 3, // 1 in production
@@ -170,29 +170,38 @@
@@ -170,29 +170,38 @@
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.
+// The headline's phrases are links already in the page; this shows them in turn, once through, then rests on the
+// first. The button pauses, or plays it again. Reduced motion keeps the first. Never an aria-live region.
(function () {
(function () {
const el = document.getElementById('tool-headline');
const el = document.getElementById('tool-headline');
- if (!el) return;
+ const items = el ? Array.from(el.querySelectorAll('.hm-ph')) : [];
+ if (items.length < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
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 control = document.getElementById('headline-motion');
+ let i = 0, timer = 0, paused = false, rested = false;
+ const show = (n) => {
+ const from = items[i], to = items[n];
+ if (from === to) return;
+ from.classList.add('is-out'); from.classList.remove('is-on'); from.setAttribute('aria-hidden', 'true'); from.tabIndex = -1;
+ setTimeout(() => from.classList.remove('is-out'), 500);
+ to.classList.add('is-on'); to.removeAttribute('aria-hidden'); to.removeAttribute('tabindex');
+ i = n;
+ };
const update = () => {
const update = () => {
- clearInterval(timer);
+ clearInterval(timer); timer = 0;
if (control) control.hidden = motion.matches;
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 (motion.matches) { show(0); return; }
+ if (!paused && !rested && !document.hidden) timer = setInterval(() => {
+ if (el.matches(':hover') || el.contains(document.activeElement)) return;
+ const next = (i + 1) % items.length;
+ show(next);
+ if (next === 0) { rested = true; update(); }
+ }, 3200);
+ if (!control) return;
+ const say = paused || rested ? 'Play the headline' : 'Pause the headline';
+ control.setAttribute('aria-label', say); control.title = say; control.classList.toggle('is-paused', paused || rested);
};
};
- 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();
- });
+ if (control) control.addEventListener('click', () => { if (rested) rested = paused = false; else paused = !paused; update(); });
motion.addEventListener('change', update);
motion.addEventListener('change', update);
document.addEventListener('visibilitychange', update);
document.addEventListener('visibilitychange', update);
update();
update();
@@ -121,6 +121,15 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
@@ -121,6 +121,15 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
/* Categories as folds (11-09): the icon and the name are the fold's title; its count sits beside them. */
/* 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 .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}
.job{border-top:1px solid var(--border,rgba(255,255,255,.08));padding-top:4px}
+/* The headline's last words change: each phrase is a link to its tool, all stacked in one cell (as tall as the
+ tallest, so nothing below moves). The one showing rises in; the one leaving lifts away. Once through, then it rests. */
+.hm-rot{display:grid}.hm-ph{grid-area:1/1;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;opacity:0;visibility:hidden;transform:translateY(.35em);
+transition:opacity var(--ov-dur-3,.32s) var(--ov-ease-out,ease-out),transform var(--ov-dur-3,.32s) var(--ov-ease-out,ease-out),visibility 0s linear var(--ov-dur-3,.32s)}
+.hm-ph.is-on{opacity:1;visibility:visible;transform:none;transition-delay:0s}.hm-ph.is-out{transform:translateY(-.35em)}
+.hm-ph:hover,.hm-ph:focus-visible{text-decoration:underline;text-decoration-thickness:.06em;text-underline-offset:.12em}
+@media (prefers-reduced-motion:reduce){.hm-ph{transition:none;transform:none}}
+/* The strip of tools drifts, so its ends fade: a chip cut by a hard edge looked broken. */
+.quick-strip{-webkit-mask-image:linear-gradient(90deg,transparent,#000 22px,#000 calc(100% - 22px),transparent);mask-image:linear-gradient(90deg,transparent,#000 22px,#000 calc(100% - 22px),transparent);padding-inline:14px;margin-inline:-14px;max-width:calc(100% + 28px)}
/* The headline's pause: a small round icon beside the words, not a line of its own. */
/* 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-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;
.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;
@@ -430,8 +439,21 @@ function pageIndex(top = [], activity = { available: false }) {
@@ -430,8 +439,21 @@ function pageIndex(top = [], activity = { available: false }) {
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 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 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 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';
+ // A tool with no hand-written phrase speaks in its own tagline: its first clause, as the end of "Small tools
+ // that …" ("Look up DNS records for any domain: A, AAAA…" → "look up DNS records for any domain"). An acronym
+ // keeps its capitals. "help with <name>" was the old fallback, and read like a form letter.
+ const clause = (t) => {
+ let s = String(t.tagline || '').split(/[:;.(]|, | — | - /)[0].trim();
+ if (s.split(/\s+/).length < 2 || s.length > 46) return null;
+ if (/^[A-Z][a-z]/.test(s)) s = s[0].toLowerCase() + s.slice(1);
+ return s;
+ };
+ const spoken = quickTools.map(t => [t, phrases[t.id] || clause(t)]).filter(([, p]) => p).slice(0, 6);
+ // Every phrase is in the page as a link to its tool, stacked in one grid cell: the cell is as tall as the tallest,
+ // so the swap never moves the page, and each tool is a plain link a crawler reads. One shows at a time.
+ const headlineHtml = spoken.length
+ ? `<span class="hm-rot" id="tool-headline">${spoken.map(([t, p], i) => `<a class="hm-ph${i ? '' : ' is-on'}" href="${esc(t.url)}"${go(t)}${i ? ' aria-hidden="true" tabindex="-1"' : ''}>${esc(p)}.</a>`).join('')}</span>`
+ : '<span class="hm-rot">finish the job.</span>';
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>`;
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.
// 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 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>`;
@@ -442,7 +464,7 @@ ${rankedSection('Trending this week', activity.week, { trend: true, note: 'Risin
@@ -442,7 +464,7 @@ ${rankedSection('Trending this week', activity.week, { trend: true, note: 'Risin
${activity.newest && activity.newest.length ? `<section><h2>Newest tools</h2>${rows(activity.newest)}</section>` : ''}`;
${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>
-<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>
+<div class="hm-title"><h1>Small tools that ${headlineHtml}</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('')}
<nav class="quick-strip" data-ovk-scroll="drift" aria-label="${rankedIds.length ? 'Popular tools' : 'Explore tools'}">${quick.join('')}</nav></div>
<nav class="quick-strip" data-ovk-scroll="drift" aria-label="${rankedIds.length ? 'Popular tools' : 'Explore tools'}">${quick.join('')}</nav></div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+// The home's headline, "Small tools that …" (the owner, 2026-11-09: slogans that "animate between" the many tools).
+// Its last words change, and each phrase is a real link to its tool, already in the page: stacked in one grid cell
+// so the swap never moves what is below, and readable by a crawler as plain links. The old one swapped bare text from
+// a data attribute, linked nowhere, looped for ever and fell back to "help with <tool name>".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+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));
+ try {
+ const home = await (await fetch(`http://127.0.0.1:${srv.address().port}/`)).text();
+ const rot = home.match(/<h1>Small tools that <span class="hm-rot" id="tool-headline">(.*?)<\/span><\/h1>/s);
+ assert.ok(rot, 'the headline ends in the rotating phrases');
+ const links = Array.from(rot[1].matchAll(/<a class="hm-ph( is-on)?" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g));
+ assert.ok(links.length >= 2 && links.length <= 6, `two to six phrases (${links.length})`);
+ assert.strictEqual(links.filter((m) => m[1]).length, 1, 'one phrase shows');
+ assert.ok(links[0][1], 'and it is the first: the page is whole without script');
+ links.forEach((m, i) => {
+ assert.match(m[2], /^(https:\/\/[a-z0-9.-]+\/|\/tool\/[a-z0-9-]+)/, `phrase ${i} links to a tool`);
+ assert.match(m[4], /^[a-z][^<]{3,48}\.$/, `phrase ${i} reads as the end of the sentence: "${m[4]}"`);
+ assert.ok(!/^help with /.test(m[4]), 'no form-letter fallback');
+ if (i) assert.ok(/aria-hidden="true"/.test(m[3]) && /tabindex="-1"/.test(m[3]), 'a phrase that is not showing is out of the tab order and unread');
+ });
+ assert.strictEqual(new Set(links.map((m) => m[2])).size, links.length, 'each phrase is a different tool');
+ assert.ok(!home.includes('data-phrases='), 'the phrases are links in the page, not a data attribute');
+
+ // The swap is CSS on shared timing, still under reduced motion; the script goes once through, then rests.
+ assert.match(home, /\.hm-rot\{display:grid\}\.hm-ph\{grid-area:1\/1;/, 'the phrases share one cell, so nothing below moves');
+ assert.match(home, /@media \(prefers-reduced-motion:reduce\)\{\.hm-ph\{transition:none;transform:none\}\}/);
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'site.js'), 'utf8');
+ assert.ok(js.includes('if (next === 0) { rested = true;'), 'once through, then it rests');
+ assert.ok(js.includes("el.matches(':hover') || el.contains(document.activeElement)"), 'it never changes under a pointer or the focus');
+ console.log(`home-headline: ${links.length} phrases, each a link to its tool; one shows; once through, then still`);
+ } finally {
+ srv.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const BUDGETS = {
@@ -13,7 +13,7 @@ const BUDGETS = {
htmlRawKB: 225, // 206 measured with all 184 crawlable tool rows; room for populated ranking rows
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
htmlBrotliKB: 29, // 26.5 measured with the complete category ItemLists
jsFiles: 6, // 5
jsFiles: 6, // 5
- jsRawKB: 365, // 361.2: the Frame plus the kit's behaviour file (kit-web.js) for the drifting strip, 2026-11-09
+ jsRawKB: 366, // 365.5: the headline's phrases as links with an animated swap (+0.6), 2026-11-10; 361.2 on 11-09 plus the Frame's growth since
jsBrotliKB: 88, // 86.2 with openvibe-shared 4.163.0, 2026-11-09
jsBrotliKB: 88, // 86.2 with openvibe-shared 4.163.0, 2026-11-09
cssFiles: 1, // 1: the kit's stylesheet
cssFiles: 1, // 1: the kit's stylesheet
externalFiles: 3, // 1 in production
externalFiles: 3, // 1 in production