openvibers/openvibe.quest Commits
The home on phones: how it works and the questions are folds (#139)
Changes
@@@ -118,3 +118,50 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }/* The full index (/quests): its filters sit under the lede. */.page > .ovk-tabs { margin-top: 6px; margin-bottom: 4px; }++++/* The phone home (2026-10-30, the owner's short-pages rule): how it works and the questions are folds, closed on a++ phone and open where there is room. Both stay in the page for the crawlers. */++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's blocks of the same kind are one at a++ time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live under++ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every pane and no chips.++ Every pane stays in the page for crawlers. */++.pick-head { display: none; }++.pick-row { display: none; }++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick-bar { margin-top: 30px; }++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }++ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }++ .pick-row::-webkit-scrollbar { display: none; }++ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }++ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }++ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .pick .pick-pane { display: none; }++ /* A link that points at a pane opens that pane on a phone too. */++ .pick .pick-pane:target { display: block !important; }++ .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }++ /* The chip names the pane, so the pane's own heading gives way to it; the link to more stays. */++ .pick .pick-pane > h2, .pick .pick-pane > h3 { display: none; }++} }++++/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it++ replaces: a heading and its content, with no card around it. */++.hm-fold-block { margin-top: 32px; }++.hm-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; cursor: pointer; list-style: none; }++.hm-fold-block > summary::-webkit-details-marker { display: none; }++.hm-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }++.hm-fold-block > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; white-space: nowrap; }++.hm-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (min-width: 641px) {++ .hm-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }++ .hm-fold-block > summary { display: block; min-height: 0; }++ .hm-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }++ .hm-fold-block > summary span { display: none; }++ .hm-fold-block .hm-sec, .hm-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }++}++@media (max-width: 640px) { .hm-fold-block { margin-top: 24px; } .hm-fold-block > summary h2 { font-size: 1.05rem; } }++/* A fold that sits inside the section it replaces adds no space of its own. */++:where(.sc-sec, .cm-sec, .hm-sec, .section) > .hm-fold-block { margin: 0; }++/* The folds hold the kit's own folds, so they get the room the section gave them. */++.hm-fold-block .kit-fold, .hm-fold-block .q-steps { margin: 0; }@@@ -26,6 +26,7 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { html, raw, table } = require('../render/html');const { send } = require('../render/layout');const quests = require('../render/quests');++const S = require('../render/short');const catalog = require('../quests/catalog');const store = require('../quests/store');@@@ -128,6 +129,17 @@ function createPageRoutes(ctx) {// The home is a taste and a door (owner 2026-10-28: "act more like an app, not a huge long web page"): the hero,// one strip of numbers, the quests as rails of cards with See all → /quests and the sites as quick filters, and// the parts read once (how it works, what is counted, the questions) as folds. The whole catalog is /quests.++ /**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — how it works++ * and the questions — are folds, closed on a phone and open where there is room. Both stay in the page.++ */++ function foldedHome(body) {++ let html = String(body);++ html = S.foldSection(html, 'id="how-it-works" aria-labelledby', 'how-it-works-fold');++ html = S.foldSection(html, 'id="questions" aria-labelledby', 'questions-fold');++ return html;++ }++r.get('/', async (req, res) => {const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);const site = String(config.baseUrl).replace(/\/+$/, '');@@@ -166,7 +178,7 @@ function createPageRoutes(ctx) {// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).styles: KIT_STYLES,-- body: html`${raw(hero)}++ body: raw(foldedHome(html`${raw(hero)}${statsStrip(p)}${yourStuff(req)}<section class="sc-sec q-rails">@@@ -198,7 +210,7 @@ ${raw(showcase.cta({title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',text: signedIn(req) ? 'Your progress, your badges, and what each quest still needs.' : 'Sign in with OpenVibe and the sites you already use start counting.',actions: signedIn(req) ? [{ label: 'Your quest log', href: '/me', primary: true }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme', primary: true }],-- }))}`,++ }))}`)),});});nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.++ *++ * 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 for crawlers, and a wider screen (or a++ * browser without :has()) shows them all and no chips.++ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup++ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow++ * screen and remembers the reader's choice.++ *++ * 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++/**++ * 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 }) head: the heading of the block, or '' when the block already has one.++ * panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', panes, bodyClass = '' }) {++ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.++ panes = (panes || []).filter((p) => p.html);++ if (!panes.length) return '';++ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still++ // opens that pane on a phone (the CSS shows the pane a link points at).++ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');++ const bar = `${head ? `<div class="pick-head"><h2>${esc(head)}</h2></div>` : ''}<div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div>`;++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar">${bar}</div><div class="pick-body${bodyClass ? ' ' + esc(bodyClass) : ''}">${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) #${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('');++}++++/**++ * A fold. The head is the heading the block had; the html is what it holds.++ *++ * foldBlock({ id, head, html })++ */++function foldBlock({ id, head, html }) {++ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;++}++++/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */++function foldScript(id) {++ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;++}++++/**++ * A section of the page's own markup as a fold. The section stays where it was; its own heading becomes the fold's++ * heading, and the fold is closed on a phone (the script) and open where there is room.++ *++ * foldSection(html, needle, id) needle: a mark of the part to fold (a class, for instance)++ */++function foldSection(html, needle, id) {++ const at = html.indexOf(needle);++ if (at < 0) return html;++ const start = html.lastIndexOf('<section', at);++ const close = html.indexOf('</section>', at);++ if (start < 0 || close < 0) return html;++ const openEnd = html.indexOf('>', start) + 1;++ const openTag = html.slice(start, openEnd);++ let inner = html.slice(openEnd, close);++ const h2 = /<h2[^>]*>[\s\S]*?<\/h2>/.exec(inner);++ if (!h2) return html;++ const head = h2[0];++ inner = inner.slice(h2.index + head.length);++ // The lede under the heading goes with it: the fold's heading says enough on a phone.++ const lede = /<p class="sc-lede"[^>]*>[\s\S]*?<\/p>/.exec(inner);++ if (lede) inner = inner.slice(0, lede.index) + inner.slice(lede.index + lede[0].length);++ const fold = `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${inner.trim()}</details>${foldScript(id)}`;++ return html.slice(0, start) + openTag + fold + html.slice(close);++}++++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript, foldSection };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): how it works and the questions are folds, closed on++ * a phone and open where there is room. Both stay in the page for the crawlers.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('how it works and the questions are folds, open in the page and closed on a phone', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200, r.text);++ for (const id of ['how-it-works-fold', 'questions-fold']) {++ assert.ok(r.text.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(r.text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);++ }++ // The content of the folds stays in the page.++ assert.match(r.text, /From what you do to a badge/);++ assert.match(r.text, /What is OpenVibe.Quest?/);++ // The sections they replace are still there.++ assert.ok(r.text.includes('id="how-it-works"'));++ assert.ok(r.text.includes('id="questions"'));++ });++++ await check('the CSS guard: a fold is a 44 px target and only the phone rules change the page', async () => {++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');++ assert.match(css, /\.hm-fold-block > summary \{[^}]*min-height: 44px/, 'a fold is a 44 px target');++ assert.match(css, /\.hm-fold-block > summary span \{ display: none; \}/, 'the "tap to open" hint is hidden where there is room');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -27,8 +27,8 @@ const BUDGETS = {jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)-- cssRawKB: 85, // 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)-- cssBrotliKB: 18.6, // 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6++ cssRawKB: 88, // 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)++ cssBrotliKB: 19.4, // 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6externalFiles: 0, // 0};
@@ -118,3 +118,50 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -118,3 +118,50 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }
.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }
/* The full index (/quests): its filters sit under the lede. */
/* The full index (/quests): its filters sit under the lede. */
.page > .ovk-tabs { margin-top: 6px; margin-bottom: 4px; }
.page > .ovk-tabs { margin-top: 6px; margin-bottom: 4px; }
+
+/* The phone home (2026-10-30, the owner's short-pages rule): how it works and the questions are folds, closed on a
+ phone and open where there is room. Both stay in the page for the crawlers. */
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's blocks of the same kind are one at a
+ time, picked with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live under
+ 640 px and only where :has() is understood, so a wider screen, or an older browser, shows every pane and no chips.
+ Every pane stays in the page for crawlers. */
+.pick-head { display: none; }
+.pick-row { display: none; }
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick-bar { margin-top: 30px; }
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
+ .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
+ .pick-row::-webkit-scrollbar { display: none; }
+ .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
+ .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
+ .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+ .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .pick .pick-pane { display: none; }
+ /* A link that points at a pane opens that pane on a phone too. */
+ .pick .pick-pane:target { display: block !important; }
+ .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }
+ /* The chip names the pane, so the pane's own heading gives way to it; the link to more stays. */
+ .pick .pick-pane > h2, .pick .pick-pane > h3 { display: none; }
+} }
+
+/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
+ replaces: a heading and its content, with no card around it. */
+.hm-fold-block { margin-top: 32px; }
+.hm-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; cursor: pointer; list-style: none; }
+.hm-fold-block > summary::-webkit-details-marker { display: none; }
+.hm-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
+.hm-fold-block > summary span { color: var(--text-muted, #7386a3); font-weight: 600; font-size: .86rem; white-space: nowrap; }
+.hm-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (min-width: 641px) {
+ .hm-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
+ .hm-fold-block > summary { display: block; min-height: 0; }
+ .hm-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px, 2.6vw, 30px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
+ .hm-fold-block > summary span { display: none; }
+ .hm-fold-block .hm-sec, .hm-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
+}
+@media (max-width: 640px) { .hm-fold-block { margin-top: 24px; } .hm-fold-block > summary h2 { font-size: 1.05rem; } }
+/* A fold that sits inside the section it replaces adds no space of its own. */
+:where(.sc-sec, .cm-sec, .hm-sec, .section) > .hm-fold-block { margin: 0; }
+/* The folds hold the kit's own folds, so they get the room the section gave them. */
+.hm-fold-block .kit-fold, .hm-fold-block .q-steps { margin: 0; }
@@ -26,6 +26,7 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
@@ -26,6 +26,7 @@ const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw, table } = require('../render/html');
const { html, raw, table } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const quests = require('../render/quests');
const quests = require('../render/quests');
+const S = require('../render/short');
const catalog = require('../quests/catalog');
const catalog = require('../quests/catalog');
const store = require('../quests/store');
const store = require('../quests/store');
@@ -128,6 +129,17 @@ function createPageRoutes(ctx) {
@@ -128,6 +129,17 @@ function createPageRoutes(ctx) {
// The home is a taste and a door (owner 2026-10-28: "act more like an app, not a huge long web page"): the hero,
// The home is a taste and a door (owner 2026-10-28: "act more like an app, not a huge long web page"): the hero,
// one strip of numbers, the quests as rails of cards with See all → /quests and the sites as quick filters, and
// one strip of numbers, the quests as rails of cards with See all → /quests and the sites as quick filters, and
// the parts read once (how it works, what is counted, the questions) as folds. The whole catalog is /quests.
// the parts read once (how it works, what is counted, the questions) as folds. The whole catalog is /quests.
+ /**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the parts a reader rarely needs — how it works
+ * and the questions — are folds, closed on a phone and open where there is room. Both stay in the page.
+ */
+ function foldedHome(body) {
+ let html = String(body);
+ html = S.foldSection(html, 'id="how-it-works" aria-labelledby', 'how-it-works-fold');
+ html = S.foldSection(html, 'id="questions" aria-labelledby', 'questions-fold');
+ return html;
+ }
+
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(12) : [], pulse()]);
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
@@ -166,7 +178,7 @@ function createPageRoutes(ctx) {
@@ -166,7 +178,7 @@ function createPageRoutes(ctx) {
// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no
// The kit's controls (numbers, filters, folds) and its cards and rails; no kit-web.js: the cards carry no
// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).
// menu, and from 760 px the rails wrap into a grid instead of scrolling (public/css/app.css).
styles: KIT_STYLES,
styles: KIT_STYLES,
- body: html`${raw(hero)}
+ body: raw(foldedHome(html`${raw(hero)}
${statsStrip(p)}
${statsStrip(p)}
${yourStuff(req)}
${yourStuff(req)}
<section class="sc-sec q-rails">
<section class="sc-sec q-rails">
@@ -198,7 +210,7 @@ ${raw(showcase.cta({
@@ -198,7 +210,7 @@ ${raw(showcase.cta({
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
text: signedIn(req) ? 'Your progress, your badges, and what each quest still needs.' : 'Sign in with OpenVibe and the sites you already use start counting.',
text: signedIn(req) ? 'Your progress, your badges, and what each quest still needs.' : 'Sign in with OpenVibe and the sites you already use start counting.',
actions: signedIn(req) ? [{ label: 'Your quest log', href: '/me', primary: true }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme', primary: true }],
actions: signedIn(req) ? [{ label: 'Your quest log', href: '/me', primary: true }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme', primary: true }],
- }))}`,
+ }))}`)),
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
+ *
+ * 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 for crawlers, and a wider screen (or a
+ * browser without :has()) shows them all and no chips.
+ * foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
+ * keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
+ * screen and remembers the reader's choice.
+ *
+ * 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+/**
+ * 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 }) head: the heading of the block, or '' when the block already has one.
+ * panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', panes, bodyClass = '' }) {
+ // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
+ panes = (panes || []).filter((p) => p.html);
+ if (!panes.length) return '';
+ const chips = panes.map((p, i) => `<input type="radio" name="pick-${esc(key)}" id="pk-${esc(key)}-${esc(p.id)}" value="${esc(p.id)}"${i === 0 ? ' checked' : ''}><label for="pk-${esc(key)}-${esc(p.id)}">${esc(p.label)}</label>`).join('');
+ // The pane carries the id the page already links to (an anchor in the hero, for instance), so a link to it still
+ // opens that pane on a phone (the CSS shows the pane a link points at).
+ const body = panes.map((p) => `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>`).join('');
+ const bar = `${head ? `<div class="pick-head"><h2>${esc(head)}</h2></div>` : ''}<div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div>`;
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar">${bar}</div><div class="pick-body${bodyClass ? ' ' + esc(bodyClass) : ''}">${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) #${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('');
+}
+
+/**
+ * A fold. The head is the heading the block had; the html is what it holds.
+ *
+ * foldBlock({ id, head, html })
+ */
+function foldBlock({ id, head, html }) {
+ return `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${html}</details>${foldScript(id)}`;
+}
+
+/** The script a fold carries: closed on a phone unless the reader opened it before, and it remembers that. */
+function foldScript(id) {
+ return `<script>(function(){var d=document.getElementById(${JSON.stringify(id)});if(!d)return;var k='ov_fold_'+${JSON.stringify(id)};var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>`;
+}
+
+/**
+ * A section of the page's own markup as a fold. The section stays where it was; its own heading becomes the fold's
+ * heading, and the fold is closed on a phone (the script) and open where there is room.
+ *
+ * foldSection(html, needle, id) needle: a mark of the part to fold (a class, for instance)
+ */
+function foldSection(html, needle, id) {
+ const at = html.indexOf(needle);
+ if (at < 0) return html;
+ const start = html.lastIndexOf('<section', at);
+ const close = html.indexOf('</section>', at);
+ if (start < 0 || close < 0) return html;
+ const openEnd = html.indexOf('>', start) + 1;
+ const openTag = html.slice(start, openEnd);
+ let inner = html.slice(openEnd, close);
+ const h2 = /<h2[^>]*>[\s\S]*?<\/h2>/.exec(inner);
+ if (!h2) return html;
+ const head = h2[0];
+ inner = inner.slice(h2.index + head.length);
+ // The lede under the heading goes with it: the fold's heading says enough on a phone.
+ const lede = /<p class="sc-lede"[^>]*>[\s\S]*?<\/p>/.exec(inner);
+ if (lede) inner = inner.slice(0, lede.index) + inner.slice(lede.index + lede[0].length);
+ const fold = `<details class="hm-fold hm-fold-block" id="${esc(id)}" open><summary>${head}<span>tap to open</span></summary>${inner.trim()}</details>${foldScript(id)}`;
+ return html.slice(0, start) + openTag + fold + html.slice(close);
+}
+
+module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript, foldSection };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): how it works and the questions are folds, closed on
+ * a phone and open where there is room. Both stay in the page for the crawlers.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('how it works and the questions are folds, open in the page and closed on a phone', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200, r.text);
+ for (const id of ['how-it-works-fold', 'questions-fold']) {
+ assert.ok(r.text.includes(`<details class="hm-fold hm-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(r.text.includes(`document.getElementById("${id}")`), `${id} has the script that closes it on a phone`);
+ }
+ // The content of the folds stays in the page.
+ assert.match(r.text, /From what you do to a badge/);
+ assert.match(r.text, /What is OpenVibe.Quest?/);
+ // The sections they replace are still there.
+ assert.ok(r.text.includes('id="how-it-works"'));
+ assert.ok(r.text.includes('id="questions"'));
+ });
+
+ await check('the CSS guard: a fold is a 44 px target and only the phone rules change the page', async () => {
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');
+ assert.match(css, /\.hm-fold-block > summary \{[^}]*min-height: 44px/, 'a fold is a 44 px target');
+ assert.match(css, /\.hm-fold-block > summary span \{ display: none; \}/, 'the "tap to open" hint is hidden where there is room');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -27,8 +27,8 @@ const BUDGETS = {
@@ -27,8 +27,8 @@ const BUDGETS = {
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
- cssRawKB: 85, // 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
- cssBrotliKB: 18.6, // 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
+ cssRawKB: 88, // 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
+ cssBrotliKB: 19.4, // 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};