openvibers/openvibe.tools Commits

The home's headline: each phrase is a link to its tool, and the swap is animated (#270)

OP OpenVibers committed 3 days ago · 1398270630 · 4 files, +96 −20

"Small tools that …" ended in bare text swapped from a data attribute: it linked nowhere, never stopped, and a tool
with no hand-written phrase became "help with http status codes". Now every phrase is a link already in the page
(stacked in one cell, so nothing below moves and a crawler reads plain links), a tool without a phrase speaks in its
own tagline's first clause, the swap rises in on the shared timing, and it goes once through and rests. The drifting
strip of tools fades at its ends instead of cutting a chip in half.

Changes

ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 142b2d9..4dc8daf 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -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();
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex c79af14..8d60985 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -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>
ddiff --git a/apps/gateway/test/home-headline.test.js b/apps/gateway/test/home-headline.test.js
nnew file mode 100644
iindex 0000000..bc6f2f9
---- /dev/null
++++ b/apps/gateway/test/home-headline.test.js
@@@ -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); });
ddiff --git a/apps/gateway/test/perf-budget.test.js b/apps/gateway/test/perf-budget.test.js
iindex c5cbac1..3f4ac46 100644
---- a/apps/gateway/test/perf-budget.test.js
++++ b/apps/gateway/test/perf-budget.test.js
@@@ -13,7 +13,7 @@ const BUDGETS = {
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
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
cssFiles: 1, // 1: the kit's stylesheet
externalFiles: 3, // 1 in production