openvibers/openvibe.tips Commits
Home on phones: one part of Tips at a time (#132)
Changes
@@@ -127,3 +127,48 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }.tp-more { position: relative; }.tp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }}++++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's five parts are one at a time, picked++ with a row of chips. Radio inputs and :has(), no script: the chips and the one-part rule live under 640 px and only++ where :has() is understood, so a wider screen, or an older browser, shows every part and no chips. Every part 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 part opens that part 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 part, so the part's own heading gives way to it. */++ .pick .pick-pane .tp-head h2, .pick .pick-pane .sc-sec > h2 { display: none; }++ .pick .pick-pane .tp-head { justify-content: flex-end; }++} }++++/* 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. */++.tp-fold-block { margin-top: 32px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }++.tp-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }++.tp-fold-block > summary::-webkit-details-marker { display: none; }++.tp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }++.tp-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }++.tp-fold-block .tp-sec { margin: 0; padding: 0 16px 16px; }++.tp-fold-block .tp-head h2 { display: none; }++.tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0 16px 16px; }++.tp-fold-block .sc-sec > h2 { display: none; }++@media (min-width: 641px) {++ .tp-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }++ .tp-fold-block > summary { display: block; min-height: 0; padding: 0; }++ .tp-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); }++ .tp-fold-block > summary span { display: none; }++ .tp-fold-block .tp-sec, .tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }++}++@media (max-width: 640px) { .tp-fold-block { margin-top: 24px; } .tp-fold-block > summary h2 { font-size: 1.05rem; } }nnew file mode 100644@@@ -0,0 +1,58 @@++'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 public/css/tips.css; 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 }) panes: [{ id, label, html }]++ */++function pickBlock({ key, label, head = '', panes }) {++ // 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('');++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${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="tp-fold tp-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>`;++}++++module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };@@@ -12,6 +12,7 @@ const fs = require('fs');const path = require('path');const appIcon = require('openvibe-shared/app-icon');const frame = require('openvibe-shared/frame');++const { foldBlock } = require('../render/short');const shell = require('openvibe-shared/shell');const SITE_NAME = 'OpenVibe.Tips';@@@ -86,12 +87,14 @@ function createLayout({ config, release }) {`<meta name="ov-boost" content="tips@${esc(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,`<script src="${asset('js/tips.js')}" defer></script>`,++ // The rules that show the picked part of a pick block are written for the page, because there is one per part.++ ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),].filter(Boolean).join('\n'),body: `<div id="navbar-mount"></div><header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header><main id="main" class="page${o.wide ? ' page-wide' : ''}">${o.body || ''}--${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}++${o.canonicalPath === '/' && o.active === 'home' ? foldBlock({ id: 'shipped', head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) }) : ''}</main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};@@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');const { esc } = require('./layout');const { VOICES } = require('../domain/profiles');const supportLinks = require('../domain/support-links');++const { pickBlock, pickRules, foldBlock } = require('../render/short');const n = (v) => Number(v || 0).toLocaleString('en-US');const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');@@@ -60,11 +61,15 @@ const SUPPORT_SERVICES = Object.freeze(['Ko-fi', 'PayPal.me', 'Patreon', 'Buy Meconst watching = (v) => (v === 1 ? '1 watching' : `${n(v)} watching`);/** A titled home section with an optional "see all" link; nothing when it has no body. */--function homeSection({ id, title, lede, more, body }) {++function homeSection({ id, title, lede, more, body, headId }) {if (!body) return '';-- return `<section class="tp-sec" aria-labelledby="${esc(id)}"><div class="tp-head"><h2 id="${esc(id)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;++ const h = headId || id;++ return `<section class="tp-sec" aria-labelledby="${esc(h)}"><div class="tp-head"><h2 id="${esc(h)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;}++/** The home's pick block, one part at a time on a phone: the ids of its panes, in order. */++const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];++/*** The home: what Tips is for, who is live on OpenVibe right now, the creators who switched their page on, what a* supporter can send, what a creator gets (overlays for any streaming app, other ways to be paid), what it costs, how it@@@ -85,7 +90,8 @@ function home({ creators, live = [] }) {],}) + homeSection({ id: 'live', title: 'Live on OpenVibe now', lede: 'Open a channel to watch, chat and support the creator.', more: { href: 'https://openvibe.live/', label: 'Everyone live' }, body: liveCards })+ homeSection({ id: 'creators', title: 'Creators on OpenVibe.Tips', lede: 'Every creator who switched their tip page on, the most recently updated first.', body: creatorCards })-- + showcase.features({++ + pickBlock({ key: 'parts', label: 'What Tips does', head: 'What Tips does', panes: [++ { id: 'send', label: 'What you can send', html: showcase.features({title: 'What you can send',items: [{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },@@@ -95,7 +101,8 @@ function home({ creators, live = [] }) {{ icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },{ icon: 'ov:shield', title: 'Your privacy, your choice', text: 'Send as Anonymous, hide the amount, or keep a message for the creator only.' },],-- }) + showcase.features({++ }) },++ { id: 'for-creators', label: 'For creators', html: showcase.features({title: 'For creators',lede: 'A tip page at openvibe.tips/your-name, set up from your dashboard in a few minutes.',items: [@@@ -106,15 +113,17 @@ function home({ creators, live = [] }) {{ icon: 'ov:tips', title: 'Other ways to be paid', text: `Link your ${SUPPORT_SERVICES.slice(0, 4).join(', ')} and ${SUPPORT_SERVICES.length - 4} more services beside your tip form.`, href: '/dashboard' },{ icon: 'ov:docs', title: 'Receipts both ways', text: 'Supporters keep a receipt for every tip; you see each one settle and reach your stream.' },],-- }) + homeSection({ id: 'services', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` })-- + showcase.steps({++ }) },++ { id: 'services', label: 'Other ways', html: homeSection({ id: 'services', headId: 'services-h', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) },++ { id: 'how', label: 'How it works', html: showcase.steps({title: 'How it works',items: [{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name, linked from their channel.' },{ title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },],-- }) + showcase.pricing({++ }) },++ { id: 'costs', label: 'What it costs', html: showcase.pricing({title: 'What it costs to send',lede: 'The least you can send of each. Every creator sets their own minimums; these are what a new page starts with.',tiers: [@@@ -125,8 +134,16 @@ function home({ creators, live = [] }) {{ name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },],note: "The creator's page shows their own minimums before you pay.",-- }) + `<section class="tp-sec" aria-labelledby="faq"><div class="tp-head"><h2 id="faq">Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`-- + showcase.network({ service: 'tips' }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });++ }) },++ ] }) + foldBlock({++ id: 'faq',++ head: '<h2 id="faq-h">Questions and answers</h2>',++ html: `<section class="tp-sec" aria-labelledby="faq-h"><div class="tp-head"><h2>Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`,++ }) + foldBlock({++ id: 'network',++ head: '<h2 id="network-h">Part of OpenVibe</h2>',++ html: showcase.network({ service: 'tips' }),++ }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });}/** The tip form: every field works as a plain POST. */@@@ -493,4 +510,5 @@ function errorPage({ status, title, message }) {return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;}--module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };++module.exports = {++ HOME_PANE_IDS, NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };@@@ -97,7 +97,9 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pages.HOME_FAQ.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) },];// Who is live comes from Live once a minute, so the page may be cached as long.-- html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }), jsonLd }));++ html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }),++ // The rule that shows the picked part of the home's pick block: one per part, written for this page.++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${require('../render/short').pickRules('parts', pages.HOME_PANE_IDS)}}}`], jsonLd }));});// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's five parts are one pick block, a row of++ * chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the++ * chips; every part stays in the page for crawlers; a wider screen shows them all and no chips.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ try {++ await check('the home is one pick block: a chip per part, the first picked, every part in the page', async () => {++ const r = await t.call('GET', '/', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ const page = r.text;++ const block = page.slice(page.indexOf('<div class="pick" id="pick-parts">'), page.indexOf('<div class="pick" id="pick-parts">') + 60000);++ assert.ok(block.startsWith('<div class="pick" id="pick-parts">'), 'the pick block is on the home');++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What Tips does">/);++ const chips = [...block.matchAll(/<input type="radio" name="pick-parts" id="pk-parts-([a-z-]+)" value="\1"( checked)?><label for="pk-parts-\1">([^<]+)<\/label>/g)];++ assert.ok(chips.length >= 5, `a chip per part (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first part is picked');++ // Every part stays in the page, with the id its chip picks.++ const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z-]+)">/g)].map((m) => m[1]);++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every part is in the page, one per chip');++ // The parts themselves are all there: nothing was removed.++ for (const title of ['What you can send', 'For creators', 'Other ways to support', 'How it works', 'What it costs to send']) {++ assert.ok(page.includes(title), `${title} is on the page`);++ }++ });++++ await check('the CSS guard: the chips are hidden and the parts all show until 640 px and where :has() is understood', async () => {++ const r = await t.call('GET', '/', { token: null });++ const page = r.text;++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-parts-send:checked\) #send\{display:block\}/, 'the rule that shows the picked part');++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'tips.css'), 'utf8');++ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');++ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');++ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');++ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the parts are hidden on a phone');++ });++++ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {++ const r = await t.call('GET', '/', { token: null });++ const page = r.text;++ for (const id of ['faq', 'network', 'shipped']) {++ assert.ok(page.includes(`<details class="tp-fold tp-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);++ assert.ok(page.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(page, /What are Vibes\?/);++ assert.match(page, /Part of OpenVibe/);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -127,3 +127,48 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }
@@ -127,3 +127,48 @@ ul.mod-list, ul.plain { list-style: none; padding: 0; }
.tp-more { position: relative; }
.tp-more { position: relative; }
.tp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
.tp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px; }
}
}
+
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's five parts are one at a time, picked
+ with a row of chips. Radio inputs and :has(), no script: the chips and the one-part rule live under 640 px and only
+ where :has() is understood, so a wider screen, or an older browser, shows every part and no chips. Every part 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 part opens that part 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 part, so the part's own heading gives way to it. */
+ .pick .pick-pane .tp-head h2, .pick .pick-pane .sc-sec > h2 { display: none; }
+ .pick .pick-pane .tp-head { justify-content: flex-end; }
+} }
+
+/* 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. */
+.tp-fold-block { margin-top: 32px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
+.tp-fold-block > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 0 16px; cursor: pointer; font-weight: 700; list-style: none; }
+.tp-fold-block > summary::-webkit-details-marker { display: none; }
+.tp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px, 2.2vw, 26px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); }
+.tp-fold-block > summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 14px; }
+.tp-fold-block .tp-sec { margin: 0; padding: 0 16px 16px; }
+.tp-fold-block .tp-head h2 { display: none; }
+.tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0 16px 16px; }
+.tp-fold-block .sc-sec > h2 { display: none; }
+@media (min-width: 641px) {
+ .tp-fold-block { border: 0; background: none; border-radius: 0; margin: 56px auto 0; padding: 0; }
+ .tp-fold-block > summary { display: block; min-height: 0; padding: 0; }
+ .tp-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); }
+ .tp-fold-block > summary span { display: none; }
+ .tp-fold-block .tp-sec, .tp-fold-block .sc-sec { margin: 0; max-width: none; padding: 0; }
+}
+@media (max-width: 640px) { .tp-fold-block { margin-top: 24px; } .tp-fold-block > summary h2 { font-size: 1.05rem; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'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 public/css/tips.css; 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 }) panes: [{ id, label, html }]
+ */
+function pickBlock({ key, label, head = '', panes }) {
+ // 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('');
+ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><div class="pick-row" role="radiogroup" aria-label="${esc(label)}">${chips}</div></div><div class="pick-body">${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="tp-fold tp-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>`;
+}
+
+module.exports = { pickBlock, pickRule, pickRules, foldBlock, foldScript };
@@ -12,6 +12,7 @@ const fs = require('fs');
@@ -12,6 +12,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const { foldBlock } = require('../render/short');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const SITE_NAME = 'OpenVibe.Tips';
const SITE_NAME = 'OpenVibe.Tips';
@@ -86,12 +87,14 @@ function createLayout({ config, release }) {
@@ -86,12 +87,14 @@ function createLayout({ config, release }) {
`<meta name="ov-boost" content="tips@${esc(RELEASE)}">`,
`<meta name="ov-boost" content="tips@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${asset('js/tips.js')}" defer></script>`,
`<script src="${asset('js/tips.js')}" defer></script>`,
+ // The rules that show the picked part of a pick block are written for the page, because there is one per part.
+ ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`),
].filter(Boolean).join('\n'),
].filter(Boolean).join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
<main id="main" class="page${o.wide ? ' page-wide' : ''}">
<main id="main" class="page${o.wide ? ' page-wide' : ''}">
${o.body || ''}
${o.body || ''}
-${o.canonicalPath === '/' && o.active === 'home' ? frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+${o.canonicalPath === '/' && o.active === 'home' ? foldBlock({ id: 'shipped', head: `<h2 id="h-shipped">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'tips', title: `Recently shipped on ${SITE_NAME}` }) }) : ''}
</main>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
@@ -9,6 +9,7 @@ const showcase = require('openvibe-shared/showcase');
const { esc } = require('./layout');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
const { VOICES } = require('../domain/profiles');
const supportLinks = require('../domain/support-links');
const supportLinks = require('../domain/support-links');
+const { pickBlock, pickRules, foldBlock } = require('../render/short');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
@@ -60,11 +61,15 @@ const SUPPORT_SERVICES = Object.freeze(['Ko-fi', 'PayPal.me', 'Patreon', 'Buy Me
@@ -60,11 +61,15 @@ const SUPPORT_SERVICES = Object.freeze(['Ko-fi', 'PayPal.me', 'Patreon', 'Buy Me
const watching = (v) => (v === 1 ? '1 watching' : `${n(v)} watching`);
const watching = (v) => (v === 1 ? '1 watching' : `${n(v)} watching`);
/** A titled home section with an optional "see all" link; nothing when it has no body. */
/** A titled home section with an optional "see all" link; nothing when it has no body. */
-function homeSection({ id, title, lede, more, body }) {
+function homeSection({ id, title, lede, more, body, headId }) {
if (!body) return '';
if (!body) return '';
- return `<section class="tp-sec" aria-labelledby="${esc(id)}"><div class="tp-head"><h2 id="${esc(id)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;
+ const h = headId || id;
+ return `<section class="tp-sec" aria-labelledby="${esc(h)}"><div class="tp-head"><h2 id="${esc(h)}">${esc(title)}</h2>${more ? `<a class="tp-more" href="${esc(more.href)}">${esc(more.label)}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? `<p class="tp-lede">${esc(lede)}</p>` : ''}${body}</section>`;
}
}
+/** The home's pick block, one part at a time on a phone: the ids of its panes, in order. */
+const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];
+
/**
/**
* The home: what Tips is for, who is live on OpenVibe right now, the creators who switched their page on, what a
* The home: what Tips is for, who is live on OpenVibe right now, the creators who switched their page on, what a
* supporter can send, what a creator gets (overlays for any streaming app, other ways to be paid), what it costs, how it
* supporter can send, what a creator gets (overlays for any streaming app, other ways to be paid), what it costs, how it
@@ -85,7 +90,8 @@ function home({ creators, live = [] }) {
@@ -85,7 +90,8 @@ function home({ creators, live = [] }) {
],
],
}) + homeSection({ id: 'live', title: 'Live on OpenVibe now', lede: 'Open a channel to watch, chat and support the creator.', more: { href: 'https://openvibe.live/', label: 'Everyone live' }, body: liveCards })
}) + homeSection({ id: 'live', title: 'Live on OpenVibe now', lede: 'Open a channel to watch, chat and support the creator.', more: { href: 'https://openvibe.live/', label: 'Everyone live' }, body: liveCards })
+ homeSection({ id: 'creators', title: 'Creators on OpenVibe.Tips', lede: 'Every creator who switched their tip page on, the most recently updated first.', body: creatorCards })
+ homeSection({ id: 'creators', title: 'Creators on OpenVibe.Tips', lede: 'Every creator who switched their tip page on, the most recently updated first.', body: creatorCards })
- + showcase.features({
+ + pickBlock({ key: 'parts', label: 'What Tips does', head: 'What Tips does', panes: [
+ { id: 'send', label: 'What you can send', html: showcase.features({
title: 'What you can send',
title: 'What you can send',
items: [
items: [
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
{ icon: 'ov:tips', title: 'A tip', text: 'Vibes straight to the creator, with an optional message.' },
@@ -95,7 +101,8 @@ function home({ creators, live = [] }) {
@@ -95,7 +101,8 @@ function home({ creators, live = [] }) {
{ icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },
{ icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },
{ icon: 'ov:shield', title: 'Your privacy, your choice', text: 'Send as Anonymous, hide the amount, or keep a message for the creator only.' },
{ icon: 'ov:shield', title: 'Your privacy, your choice', text: 'Send as Anonymous, hide the amount, or keep a message for the creator only.' },
],
],
- }) + showcase.features({
+ }) },
+ { id: 'for-creators', label: 'For creators', html: showcase.features({
title: 'For creators',
title: 'For creators',
lede: 'A tip page at openvibe.tips/your-name, set up from your dashboard in a few minutes.',
lede: 'A tip page at openvibe.tips/your-name, set up from your dashboard in a few minutes.',
items: [
items: [
@@ -106,15 +113,17 @@ function home({ creators, live = [] }) {
@@ -106,15 +113,17 @@ function home({ creators, live = [] }) {
{ icon: 'ov:tips', title: 'Other ways to be paid', text: `Link your ${SUPPORT_SERVICES.slice(0, 4).join(', ')} and ${SUPPORT_SERVICES.length - 4} more services beside your tip form.`, href: '/dashboard' },
{ icon: 'ov:tips', title: 'Other ways to be paid', text: `Link your ${SUPPORT_SERVICES.slice(0, 4).join(', ')} and ${SUPPORT_SERVICES.length - 4} more services beside your tip form.`, href: '/dashboard' },
{ icon: 'ov:docs', title: 'Receipts both ways', text: 'Supporters keep a receipt for every tip; you see each one settle and reach your stream.' },
{ icon: 'ov:docs', title: 'Receipts both ways', text: 'Supporters keep a receipt for every tip; you see each one settle and reach your stream.' },
],
],
- }) + homeSection({ id: 'services', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` })
- + showcase.steps({
+ }) },
+ { id: 'services', label: 'Other ways', html: homeSection({ id: 'services', headId: 'services-h', title: 'Other ways to support', lede: 'A creator can list these beside their tip form. Each link is built from the creator\'s handle on that service\'s own site, and it is paid there, outside OpenVibe.', body: `<ul class="tp-chips">${SUPPORT_SERVICES.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) },
+ { id: 'how', label: 'How it works', html: showcase.steps({
title: 'How it works',
title: 'How it works',
items: [
items: [
{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name, linked from their channel.' },
{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name, linked from their channel.' },
{ title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },
{ title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
],
],
- }) + showcase.pricing({
+ }) },
+ { id: 'costs', label: 'What it costs', html: showcase.pricing({
title: 'What it costs to send',
title: 'What it costs to send',
lede: 'The least you can send of each. Every creator sets their own minimums; these are what a new page starts with.',
lede: 'The least you can send of each. Every creator sets their own minimums; these are what a new page starts with.',
tiers: [
tiers: [
@@ -125,8 +134,16 @@ function home({ creators, live = [] }) {
@@ -125,8 +134,16 @@ function home({ creators, live = [] }) {
{ name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },
{ name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },
],
],
note: "The creator's page shows their own minimums before you pay.",
note: "The creator's page shows their own minimums before you pay.",
- }) + `<section class="tp-sec" aria-labelledby="faq"><div class="tp-head"><h2 id="faq">Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`
- + showcase.network({ service: 'tips' }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });
+ }) },
+ ] }) + foldBlock({
+ id: 'faq',
+ head: '<h2 id="faq-h">Questions and answers</h2>',
+ html: `<section class="tp-sec" aria-labelledby="faq-h"><div class="tp-head"><h2>Questions and answers</h2></div><div class="tp-faq">${HOME_FAQ.map((x) => `<details><summary>${esc(x.q)}</summary><p>${esc(x.a)}</p></details>`).join('')}</div></section>`,
+ }) + foldBlock({
+ id: 'network',
+ head: '<h2 id="network-h">Part of OpenVibe</h2>',
+ html: showcase.network({ service: 'tips' }),
+ }) + showcase.cta({ title: 'Stream? Get tipped anywhere.', text: 'Start a tip page, add the overlay to your streaming app and share your link.', actions: [{ label: 'Start your tip page', href: '/dashboard', primary: true }, { label: 'Memberships on OpenVibe.VIP', href: 'https://openvibe.vip/' }] });
}
}
/** The tip form: every field works as a plain POST. */
/** The tip form: every field works as a plain POST. */
@@ -493,4 +510,5 @@ function errorPage({ status, title, message }) {
@@ -493,4 +510,5 @@ function errorPage({ status, title, message }) {
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
}
}
-module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
+module.exports = {
+ HOME_PANE_IDS, NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
@@ -97,7 +97,9 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -97,7 +97,9 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pages.HOME_FAQ.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) },
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pages.HOME_FAQ.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) },
];
];
// Who is live comes from Live once a minute, so the page may be cached as long.
// Who is live comes from Live once a minute, so the page may be cached as long.
- html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }), jsonLd }));
+ html(res, pageFor(req, { active: 'home', canonicalPath: '/', title: 'Tips, paid messages and stream alerts', description: HOME_DESCRIPTION, styles: [require('openvibe-shared/showcase').STYLESHEET], wide: true, body: pages.home({ creators, live }),
+ // The rule that shows the picked part of the home's pick block: one per part, written for this page.
+ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${require('../render/short').pickRules('parts', pages.HOME_PANE_IDS)}}}`], jsonLd }));
});
});
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's five parts are one pick block, a row of
+ * chips picks one of them, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the
+ * chips; every part stays in the page for crawlers; a wider screen shows them all and no chips.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the home is one pick block: a chip per part, the first picked, every part in the page', async () => {
+ const r = await t.call('GET', '/', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ const page = r.text;
+ const block = page.slice(page.indexOf('<div class="pick" id="pick-parts">'), page.indexOf('<div class="pick" id="pick-parts">') + 60000);
+ assert.ok(block.startsWith('<div class="pick" id="pick-parts">'), 'the pick block is on the home');
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="What Tips does">/);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-parts" id="pk-parts-([a-z-]+)" value="\1"( checked)?><label for="pk-parts-\1">([^<]+)<\/label>/g)];
+ assert.ok(chips.length >= 5, `a chip per part (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first part is picked');
+ // Every part stays in the page, with the id its chip picks.
+ const panes = [...block.matchAll(/<div class="pick-pane" id="([a-z-]+)">/g)].map((m) => m[1]);
+ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every part is in the page, one per chip');
+ // The parts themselves are all there: nothing was removed.
+ for (const title of ['What you can send', 'For creators', 'Other ways to support', 'How it works', 'What it costs to send']) {
+ assert.ok(page.includes(title), `${title} is on the page`);
+ }
+ });
+
+ await check('the CSS guard: the chips are hidden and the parts all show until 640 px and where :has() is understood', async () => {
+ const r = await t.call('GET', '/', { token: null });
+ const page = r.text;
+ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-parts-send:checked\) #send\{display:block\}/, 'the rule that shows the picked part');
+ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'tips.css'), 'utf8');
+ assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
+ assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
+ assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
+ assert.match(css, /\.pick \.pick-pane \{ display: none; \}/, 'the parts are hidden on a phone');
+ });
+
+ await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
+ const r = await t.call('GET', '/', { token: null });
+ const page = r.text;
+ for (const id of ['faq', 'network', 'shipped']) {
+ assert.ok(page.includes(`<details class="tp-fold tp-fold-block" id="${id}" open>`), `${id} is a fold, open in the page`);
+ assert.ok(page.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(page, /What are Vibes\?/);
+ assert.match(page, /Part of OpenVibe/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });