openvibers/openvibe.coupons Commits
Short home on phones: one thing at a time, picked with chips (#153)
Changes
@@@ -168,3 +168,60 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--.cp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }.cp-card h3 a { display: inline-block; min-height: 44px; }}++++++/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a++ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only++ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */++.pick-head { display: none; }++.pick-row { display: none; }++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick-bar { margin-top: 0; }++ .pick-head { display: block; }++ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,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; }++ .pick .pick-list > li { display: none; }++ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the++ crawlers and for the reader on a wide screen. */++ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { 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. */++.cp-fold-block { margin-top: 32px; }++.cp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++.cp-fold-hint { display: none; }++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.cp-fold-block .sc-sec > h2, .cp-fold-block > .sc-sec > h2 { display: none; }++@media (min-width: 641px) {++ .cp-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }++ .cp-fold-block > summary { display: block; min-height: 0; padding: 0; }++ .cp-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }++ .cp-fold-block > summary span { display: none; }++ .cp-fold-block .sc-sec { margin: 0; padding: 0; }++}++@media (max-width: 640px) {++ .cp-fold-block { margin-top: 24px; }++ .cp-fold-block > summary { cursor: pointer; }++ .cp-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }++}++++/* A link that points into a pick block opens it: the pane carries the id the link names. */++@supports selector(:has(*)) { @media (max-width: 640px) {++ .pick .pick-pane:target { display: block; }++} }++++/* The fold's summary carries the heading, so the section's own heading gives way to it. */++.cp-fold-block .cp-sec > .cp-head > h2 { display: none; }++++/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */++@media (min-width: 641px) {++ .pick, .pick-body, .pick-pane { display: contents; }++ .pick-bar { display: none; }++}@@@ -29,6 +29,8 @@ const { renderPage, oneGraph } = require('../render/layout');const pages = require('../render/pages');const offer = require('../render/offer');const home = require('../render/home');++// The short home (the owner's rule, 2026-10-30): pick blocks and folds.++const { pickRules } = require('../render/short');const { csrfToken, checkCsrf } = require('../auth/forms');const { ApiError } = require('./errors');const confidence = require('../domain/confidence');@@@ -170,6 +172,8 @@ function createPublicRoutes(ctx) {decision: pageDecision('/'), canonical: `${config.baseUrl}/`,// The service's own one-line summary: the shell turns it into the ai-summary meta and the WebPage JSON-LD.summary: pages.SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home',++ // The rule that shows the picked block of the codes' pick block: one per block, written for this page.++ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('codes', ['cp-working', 'cp-ending', 'cp-new'])}}}`],feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Coupons', url: `${config.baseUrl}/`, potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl}/?q={q}`, 'query-input': 'required name=q' } },@@@ -11,6 +11,9 @@const ssr = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');const { STATUS_LABEL, shopsSection } = require('./pages');++// The short home (the owner's rule, 2026-10-30): the three code lists become one pick block, a row of chips picks++// one of them, and the parts a reader rarely needs become folds.++const { pickBlock, foldBlock } = require('./short');const { html: h, raw } = ssr;const count = (n) => Number(n).toLocaleString('en-US');@@@ -37,10 +40,10 @@ ${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" re</li>`;}--function section({ id, title, lede, more, body }) {++function section({ id, title, lede, more, body, domId = id }) {if (!body) return '';-- return h`<section class="cp-sec" aria-labelledby="${id}">--<div class="cp-head"><h2 id="${id}">${title}</h2>${more ? h`<a class="cp-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>++ return h`<section class="cp-sec" aria-labelledby="${domId}">++<div class="cp-head"><h2 id="${domId}">${title}</h2>${more ? h`<a class="cp-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>${lede ? h`<p class="cp-lede">${lede}</p>` : ''}${raw(String(body))}</section>`;@@@ -94,11 +97,21 @@ function homePage(d) {return h`${hero}${s.active === 0 ? h`<p class="empty cp-empty">No codes are listed yet. <a href="/submit">Submit the first one</a>.</p>` : ''}--${section({ id: 'cp-working', title: 'Reported working', lede: `Codes people said worked when they tried them in the last ${d.windowDays} days, the most recent first.`, body: cards(d.working, d.now) })}--${section({ id: 'cp-ending', title: 'Ending soon', lede: 'Codes whose stated end is in the next seven days, soonest first.', body: cards(d.ending, d.now) })}--${section({ id: 'cp-new', title: 'New codes', lede: 'The latest codes added, newest first. Copy one and paste it at the shop\'s checkout.', more: { href: '/feed.xml', label: 'Follow new codes (RSS)' }, body: cards(d.recent, d.now) })}++${raw(pickBlock({++ key: 'codes',++ label: 'Codes',++ head: 'Codes',++ panes: [++ { id: 'cp-working', label: 'Reported working', html: section({ id: 'cp-working', domId: 'cp-working-h', title: 'Reported working', lede: `Codes people said worked when they tried them in the last ${d.windowDays} days, the most recent first.`, body: cards(d.working, d.now) }) },++ { id: 'cp-ending', label: 'Ending soon', html: section({ id: 'cp-ending', domId: 'cp-ending-h', title: 'Ending soon', lede: 'Codes whose stated end is in the next seven days, soonest first.', body: cards(d.ending, d.now) }) },++ { id: 'cp-new', label: 'New codes', html: section({ id: 'cp-new', domId: 'cp-new-h', title: 'New codes', lede: 'The latest codes added, newest first. Copy one and paste it at the shop\'s checkout.', more: { href: '/feed.xml', label: 'Follow new codes (RSS)' }, body: cards(d.recent, d.now) }) },++ ],++}))}${raw(String(shopsSection(d.merchants, d.total)))}--${raw(showcase.features({++${raw(foldBlock({++ id: 'companion',++ head: '<h2 id="cp-companion-h">The OpenVibe Companion</h2>',++ html: showcase.features({title: 'The OpenVibe Companion',lede: 'A free, open-source browser extension for Chrome, Edge, Brave and Firefox.',items: [@@@ -106,7 +119,8 @@ ${raw(showcase.features({{ icon: 'ov:check', title: 'Tried when you ask', text: 'At checkout one button types the codes into the shop\'s own coupon box and reads the total before and after.', href: '/companion' },{ icon: 'ov:shield', title: 'No referral games', text: 'It never rewrites links or adds referral tags, so whoever sent you keeps the credit.', href: '/companion' },],-- }))}++ }),++}))}<section class="cp-sec" aria-labelledby="cp-tells"><details class="cp-fold"><summary><h2 id="cp-tells">What every code tells you</h2><span>the shop, the rules, the end, the reports</span></summary>${raw(showcase.features({items: [{ icon: 'ov:coupons', title: 'The right shop', text: 'Each shop lists the domains and paths it covers, so a code is matched to the site you are on.' },@@@ -115,9 +129,17 @@ ${raw(showcase.features({{ icon: 'ov:community', title: 'Reports from people', text: 'Worked or did not work, recently: the status comes from what people said when they tried it.', href: '/about#statuses' },],}))}</details></section>--<section class="cp-sec" aria-labelledby="cp-faq"><div class="cp-head"><h2 id="cp-faq">Questions and answers</h2></div>--<div class="cp-faq">${d.faq.map((x) => h`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>--${raw(showcase.network({ service: 'coupons' }))}++${raw(foldBlock({++ id: 'faq',++ head: '<h2 id="cp-faq-h">Questions and answers</h2>',++ html: h`<section class="cp-sec" aria-labelledby="cp-faq-h"><div class="cp-head"><h2 id="cp-faq-h-in">Questions and answers</h2></div>++<div class="cp-faq">${d.faq.map((x) => h`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>`,++}))}++${raw(foldBlock({++ id: 'network',++ head: '<h2 id="cp-network-h">Part of OpenVibe</h2>',++ html: showcase.network({ service: 'coupons' }),++}))}<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;}@@@ -15,6 +15,7 @@ const fs = require('fs');const path = require('path');const layout = require('openvibe-publishing/layout');const frame = require('openvibe-shared/frame');++const { foldBlock } = require('./short');const ovServe = require('openvibe-shared/serve');/**@@@ -98,7 +99,8 @@ function renderPage(o) {description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',canonical: o.canonical,// The head names the page's own Markdown twin (openvibe-shared/agent-layer).-- head: agentHeadTags(o),++ // The rules that show the picked block of a pick block are written for the page, because there is one per block.++ head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),decision: o.decision,// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta// and a WebPage JSON-LD tag from these real fields; with none it emits neither.@@@ -119,7 +121,8 @@ function renderPage(o) {release: RELEASE,account,body: o.body,-- shipped: o.path === '/' ? frame.shipped({ service: 'coupons', title: `Recently shipped on ${SITE_NAME}` }) : '',++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.++ shipped: o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="cp-shipped-h">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'coupons', title: `Recently shipped on ${SITE_NAME}` }) }) : '',bodyClass: o.bodyClass,});}nnew file mode 100644@@@ -0,0 +1,109 @@++'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.++ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of 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 this 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 }) 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('');++ 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 pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list++ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.++ *++ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items++ */++function pickList({ key, label, head = '', labels, html }) {++ labels = labels || [];++ if (!labels.length) return '';++ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').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>' + html + '</div>';++}++++/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */++function pickListRule(key, i) {++ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';++}++++/** The rules for every item of a pick list. */++function pickListRules(key, n) {++ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).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="cp-fold cp-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="cp-fold-hint">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 part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a++ * fold. { start, end, html } — the section that holds the needle.++ */++function part(html, needle) {++ const at = html.indexOf(needle);++ if (at < 0) return null;++ const start = html.lastIndexOf('<section', at);++ const close = html.indexOf('</section>', at);++ if (start < 0 || close < 0) return null;++ const end = close + '</section>'.length;++ return { start, end, html: html.slice(start, end) };++}++++/** The same part, but replaced by something else. */++function swap(html, needle, withHtml) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + withHtml + html.slice(p.end);++}++++/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */++function foldAround(html, needle, id, head) {++ const p = part(html, needle);++ if (!p) return html;++ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);++}++++module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * The home on a phone (the owner's short-pages rule, 2026-10-30): the three code lists 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 list stays in the page for crawlers; a wider screen shows them all and no chips.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ try {++ await check('setup: a shop and a code, so the lists have something to show', async () => {++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });++ const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ });++++ await check('the codes are one pick block: a chip per list, the first picked, every list in the page', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200, r.text);++ const page = r.text;++ const start = page.indexOf('<div class="pick" id="pick-codes">');++ assert.ok(start > 0, 'the pick block is on the home');++ const block = page.slice(start, page.indexOf('<section class="cp-sec" id="shops"'));++ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Codes">/);++ const chips = [...block.matchAll(/<input type="radio" name="pick-codes" id="pk-codes-([a-z-]+)" value="([a-z-]+)"( checked)?><label for="pk-codes-[a-z-]+">([^<]+)<\/label>/g)];++ assert.ok(chips.length >= 1, `a chip per list (${chips.length})`);++ assert.deepStrictEqual(chips.filter((m) => m[3]).map((m) => m[1]), [chips[0][1]], 'the first list is picked');++ // The lists that have something to show stay in the page, each in its own pane.++ for (const id of chips.map((m) => m[1])) {++ assert.ok(block.includes(`id="${id}"`), `the pane of ${id} is in the page`);++ }++ assert.ok(block.includes('VEG5'), 'the code is on the page');++ });++++ await check('the CSS guard: the chips are hidden and the lists all show until 640 px and where :has() is understood', async () => {++ const r = await t.get('/');++ const page = r.text;++ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-codes-cp-working:checked\) #cp-working\{display:block\}/, 'the rule that shows the picked list');++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'coupons.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 lists 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.get('/');++ const page = r.text;++ for (const id of ['companion', 'faq', 'network', 'shipped']) {++ assert.ok(page.includes(`<details class="cp-fold cp-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, /<h2 id="cp-companion-h">The OpenVibe Companion<\/h2>/);++ assert.match(page, /<h2 id="cp-faq-h">Questions and answers<\/h2>/);++ assert.match(page, /<h2 id="cp-network-h">Part of OpenVibe<\/h2>/);++ assert.match(page, /<h2 id="cp-shipped-h">Recently shipped on OpenVibe.Coupons<\/h2>/);++ assert.match(page, /<summary>Do these codes work\?<\/summary>/, 'the questions are still on the page');++ });++ } finally {++ await done(t);++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 43, // 42.3 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)++ htmlRawKB: 46, // 45.1 with the short home (2026-10-30: the code lists are one pick block and the rare parts are folds); 42.3 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)jsFiles: 6, // 6 with the OpenVibe Kit's kit-web.js (Shared 4.20.1, 2026-10-28); 5 (openvibe-shared 2.9 shell: web-runtime.js)jsRawKB: 334, // 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 327.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); 319.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 312.4 openvibe-shared 4.29.2 (2026-10-28): Notifications 2.0's toast layer (held while a menu is open, grouping, priority, System cards) and the menu's results view; 307.6 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; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 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; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js) // raised 316 -> 325 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it didjsBrotliKB: 83, // 82.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 80.9 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); 79.3 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 76.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; 73.5 Shared 4.20.1 (kit-web.js); 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; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js) // raised 78 -> 80 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it didcssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)-- cssRawKB: 72, // 70.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); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did-- cssBrotliKB: 14.8, // 14.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); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did++ cssRawKB: 74, // 73.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 70.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); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did++ cssBrotliKB: 15.4, // 15.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 14.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); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it didexternalFiles: 1, // 0};
@@ -168,3 +168,60 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@@ -168,3 +168,60 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.cp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }
.cp-more::after { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px; }
.cp-card h3 a { display: inline-block; min-height: 44px; }
.cp-card h3 a { display: inline-block; min-height: 44px; }
}
}
+
+
+/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a
+ row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only
+ where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
+.pick-head { display: none; }
+.pick-row { display: none; }
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick-bar { margin-top: 0; }
+ .pick-head { display: block; }
+ .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,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; }
+ .pick .pick-list > li { display: none; }
+ /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the
+ crawlers and for the reader on a wide screen. */
+ .pick .sc-sec > h2, .pick section > h2, .pick > h2 { 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. */
+.cp-fold-block { margin-top: 32px; }
+.cp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+.cp-fold-hint { display: none; }
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.cp-fold-block .sc-sec > h2, .cp-fold-block > .sc-sec > h2 { display: none; }
+@media (min-width: 641px) {
+ .cp-fold-block { border: 0; background: none; border-radius: 0; margin-top: 56px; padding: 0; }
+ .cp-fold-block > summary { display: block; min-height: 0; padding: 0; }
+ .cp-fold-block > summary h2 { margin: 0 0 6px; font: 700 clamp(22px,2.6vw,30px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
+ .cp-fold-block > summary span { display: none; }
+ .cp-fold-block .sc-sec { margin: 0; padding: 0; }
+}
+@media (max-width: 640px) {
+ .cp-fold-block { margin-top: 24px; }
+ .cp-fold-block > summary { cursor: pointer; }
+ .cp-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
+}
+
+/* A link that points into a pick block opens it: the pane carries the id the link names. */
+@supports selector(:has(*)) { @media (max-width: 640px) {
+ .pick .pick-pane:target { display: block; }
+} }
+
+/* The fold's summary carries the heading, so the section's own heading gives way to it. */
+.cp-fold-block .cp-sec > .cp-head > h2 { display: none; }
+
+/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */
+@media (min-width: 641px) {
+ .pick, .pick-body, .pick-pane { display: contents; }
+ .pick-bar { display: none; }
+}
@@ -29,6 +29,8 @@ const { renderPage, oneGraph } = require('../render/layout');
@@ -29,6 +29,8 @@ const { renderPage, oneGraph } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
const offer = require('../render/offer');
const offer = require('../render/offer');
const home = require('../render/home');
const home = require('../render/home');
+// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
+const { pickRules } = require('../render/short');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError } = require('./errors');
const { ApiError } = require('./errors');
const confidence = require('../domain/confidence');
const confidence = require('../domain/confidence');
@@ -170,6 +172,8 @@ function createPublicRoutes(ctx) {
@@ -170,6 +172,8 @@ function createPublicRoutes(ctx) {
decision: pageDecision('/'), canonical: `${config.baseUrl}/`,
decision: pageDecision('/'), canonical: `${config.baseUrl}/`,
// The service's own one-line summary: the shell turns it into the ai-summary meta and the WebPage JSON-LD.
// The service's own one-line summary: the shell turns it into the ai-summary meta and the WebPage JSON-LD.
summary: pages.SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home',
summary: pages.SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home',
+ // The rule that shows the picked block of the codes' pick block: one per block, written for this page.
+ inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickRules('codes', ['cp-working', 'cp-ending', 'cp-new'])}}}`],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'New codes (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New codes (Atom)' }, { type: 'json', href: '/feed.json', title: 'New codes (JSON Feed)' }],
jsonLd: [
jsonLd: [
{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Coupons', url: `${config.baseUrl}/`, potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl}/?q={q}`, 'query-input': 'required name=q' } },
{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Coupons', url: `${config.baseUrl}/`, potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl}/?q={q}`, 'query-input': 'required name=q' } },
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { STATUS_LABEL, shopsSection } = require('./pages');
const { STATUS_LABEL, shopsSection } = require('./pages');
+// The short home (the owner's rule, 2026-10-30): the three code lists become one pick block, a row of chips picks
+// one of them, and the parts a reader rarely needs become folds.
+const { pickBlock, foldBlock } = require('./short');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
const count = (n) => Number(n).toLocaleString('en-US');
const count = (n) => Number(n).toLocaleString('en-US');
@@ -37,10 +40,10 @@ ${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" re
@@ -37,10 +40,10 @@ ${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" re
</li>`;
</li>`;
}
}
-function section({ id, title, lede, more, body }) {
+function section({ id, title, lede, more, body, domId = id }) {
if (!body) return '';
if (!body) return '';
- return h`<section class="cp-sec" aria-labelledby="${id}">
-<div class="cp-head"><h2 id="${id}">${title}</h2>${more ? h`<a class="cp-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
+ return h`<section class="cp-sec" aria-labelledby="${domId}">
+<div class="cp-head"><h2 id="${domId}">${title}</h2>${more ? h`<a class="cp-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
${lede ? h`<p class="cp-lede">${lede}</p>` : ''}
${lede ? h`<p class="cp-lede">${lede}</p>` : ''}
${raw(String(body))}
${raw(String(body))}
</section>`;
</section>`;
@@ -94,11 +97,21 @@ function homePage(d) {
@@ -94,11 +97,21 @@ function homePage(d) {
return h`${hero}
return h`${hero}
${s.active === 0 ? h`<p class="empty cp-empty">No codes are listed yet. <a href="/submit">Submit the first one</a>.</p>` : ''}
${s.active === 0 ? h`<p class="empty cp-empty">No codes are listed yet. <a href="/submit">Submit the first one</a>.</p>` : ''}
-${section({ id: 'cp-working', title: 'Reported working', lede: `Codes people said worked when they tried them in the last ${d.windowDays} days, the most recent first.`, body: cards(d.working, d.now) })}
-${section({ id: 'cp-ending', title: 'Ending soon', lede: 'Codes whose stated end is in the next seven days, soonest first.', body: cards(d.ending, d.now) })}
-${section({ id: 'cp-new', title: 'New codes', lede: 'The latest codes added, newest first. Copy one and paste it at the shop\'s checkout.', more: { href: '/feed.xml', label: 'Follow new codes (RSS)' }, body: cards(d.recent, d.now) })}
+${raw(pickBlock({
+ key: 'codes',
+ label: 'Codes',
+ head: 'Codes',
+ panes: [
+ { id: 'cp-working', label: 'Reported working', html: section({ id: 'cp-working', domId: 'cp-working-h', title: 'Reported working', lede: `Codes people said worked when they tried them in the last ${d.windowDays} days, the most recent first.`, body: cards(d.working, d.now) }) },
+ { id: 'cp-ending', label: 'Ending soon', html: section({ id: 'cp-ending', domId: 'cp-ending-h', title: 'Ending soon', lede: 'Codes whose stated end is in the next seven days, soonest first.', body: cards(d.ending, d.now) }) },
+ { id: 'cp-new', label: 'New codes', html: section({ id: 'cp-new', domId: 'cp-new-h', title: 'New codes', lede: 'The latest codes added, newest first. Copy one and paste it at the shop\'s checkout.', more: { href: '/feed.xml', label: 'Follow new codes (RSS)' }, body: cards(d.recent, d.now) }) },
+ ],
+}))}
${raw(String(shopsSection(d.merchants, d.total)))}
${raw(String(shopsSection(d.merchants, d.total)))}
-${raw(showcase.features({
+${raw(foldBlock({
+ id: 'companion',
+ head: '<h2 id="cp-companion-h">The OpenVibe Companion</h2>',
+ html: showcase.features({
title: 'The OpenVibe Companion',
title: 'The OpenVibe Companion',
lede: 'A free, open-source browser extension for Chrome, Edge, Brave and Firefox.',
lede: 'A free, open-source browser extension for Chrome, Edge, Brave and Firefox.',
items: [
items: [
@@ -106,7 +119,8 @@ ${raw(showcase.features({
@@ -106,7 +119,8 @@ ${raw(showcase.features({
{ icon: 'ov:check', title: 'Tried when you ask', text: 'At checkout one button types the codes into the shop\'s own coupon box and reads the total before and after.', href: '/companion' },
{ icon: 'ov:check', title: 'Tried when you ask', text: 'At checkout one button types the codes into the shop\'s own coupon box and reads the total before and after.', href: '/companion' },
{ icon: 'ov:shield', title: 'No referral games', text: 'It never rewrites links or adds referral tags, so whoever sent you keeps the credit.', href: '/companion' },
{ icon: 'ov:shield', title: 'No referral games', text: 'It never rewrites links or adds referral tags, so whoever sent you keeps the credit.', href: '/companion' },
],
],
- }))}
+ }),
+}))}
<section class="cp-sec" aria-labelledby="cp-tells"><details class="cp-fold"><summary><h2 id="cp-tells">What every code tells you</h2><span>the shop, the rules, the end, the reports</span></summary>${raw(showcase.features({
<section class="cp-sec" aria-labelledby="cp-tells"><details class="cp-fold"><summary><h2 id="cp-tells">What every code tells you</h2><span>the shop, the rules, the end, the reports</span></summary>${raw(showcase.features({
items: [
items: [
{ icon: 'ov:coupons', title: 'The right shop', text: 'Each shop lists the domains and paths it covers, so a code is matched to the site you are on.' },
{ icon: 'ov:coupons', title: 'The right shop', text: 'Each shop lists the domains and paths it covers, so a code is matched to the site you are on.' },
@@ -115,9 +129,17 @@ ${raw(showcase.features({
@@ -115,9 +129,17 @@ ${raw(showcase.features({
{ icon: 'ov:community', title: 'Reports from people', text: 'Worked or did not work, recently: the status comes from what people said when they tried it.', href: '/about#statuses' },
{ icon: 'ov:community', title: 'Reports from people', text: 'Worked or did not work, recently: the status comes from what people said when they tried it.', href: '/about#statuses' },
],
],
}))}</details></section>
}))}</details></section>
-<section class="cp-sec" aria-labelledby="cp-faq"><div class="cp-head"><h2 id="cp-faq">Questions and answers</h2></div>
-<div class="cp-faq">${d.faq.map((x) => h`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>
-${raw(showcase.network({ service: 'coupons' }))}
+${raw(foldBlock({
+ id: 'faq',
+ head: '<h2 id="cp-faq-h">Questions and answers</h2>',
+ html: h`<section class="cp-sec" aria-labelledby="cp-faq-h"><div class="cp-head"><h2 id="cp-faq-h-in">Questions and answers</h2></div>
+<div class="cp-faq">${d.faq.map((x) => h`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>`,
+}))}
+${raw(foldBlock({
+ id: 'network',
+ head: '<h2 id="cp-network-h">Part of OpenVibe</h2>',
+ html: showcase.network({ service: 'coupons' }),
+}))}
<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
}
}
@@ -15,6 +15,7 @@ const fs = require('fs');
@@ -15,6 +15,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const { foldBlock } = require('./short');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
/**
/**
@@ -98,7 +99,8 @@ function renderPage(o) {
@@ -98,7 +99,8 @@ function renderPage(o) {
description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',
description: o.description || 'Coupon codes with merchant matching, restrictions, honest expiry and people\'s validity reports.',
canonical: o.canonical,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
- head: agentHeadTags(o),
+ // The rules that show the picked block of a pick block are written for the page, because there is one per block.
+ head: [agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
decision: o.decision,
decision: o.decision,
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
@@ -119,7 +121,8 @@ function renderPage(o) {
@@ -119,7 +121,8 @@ function renderPage(o) {
release: RELEASE,
release: RELEASE,
account,
account,
body: o.body,
body: o.body,
- shipped: o.path === '/' ? frame.shipped({ service: 'coupons', title: `Recently shipped on ${SITE_NAME}` }) : '',
+ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
+ shipped: o.path === '/' ? foldBlock({ id: 'shipped', head: `<h2 id="cp-shipped-h">Recently shipped on ${SITE_NAME}</h2>`, html: frame.shipped({ service: 'coupons', title: `Recently shipped on ${SITE_NAME}` }) }) : '',
bodyClass: o.bodyClass,
bodyClass: o.bodyClass,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'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.
+ * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of 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 this 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 }) 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('');
+ 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 pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list
+ * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.
+ *
+ * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items
+ */
+function pickList({ key, label, head = '', labels, html }) {
+ labels = labels || [];
+ if (!labels.length) return '';
+ const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').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>' + html + '</div>';
+}
+
+/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */
+function pickListRule(key, i) {
+ return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';
+}
+
+/** The rules for every item of a pick list. */
+function pickListRules(key, n) {
+ return Array.from({ length: n }, (_, i) => pickListRule(key, i)).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="cp-fold cp-fold-block" id="' + esc(id) + '" open><summary>' + head + '<span class="cp-fold-hint">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 part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a
+ * fold. { start, end, html } — the section that holds the needle.
+ */
+function part(html, needle) {
+ const at = html.indexOf(needle);
+ if (at < 0) return null;
+ const start = html.lastIndexOf('<section', at);
+ const close = html.indexOf('</section>', at);
+ if (start < 0 || close < 0) return null;
+ const end = close + '</section>'.length;
+ return { start, end, html: html.slice(start, end) };
+}
+
+/** The same part, but replaced by something else. */
+function swap(html, needle, withHtml) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + withHtml + html.slice(p.end);
+}
+
+/** The part that carries this heading, as a fold: closed on a phone, the section it was where there is room. */
+function foldAround(html, needle, id, head) {
+ const p = part(html, needle);
+ if (!p) return html;
+ return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);
+}
+
+module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * The home on a phone (the owner's short-pages rule, 2026-10-30): the three code lists 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 list stays in the page for crawlers; a wider screen shows them all and no chips.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ try {
+ await check('setup: a shop and a code, so the lists have something to show', async () => {
+ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
+ const r = await t.get('/submit', { as: alice, form: { csrf: t.csrf(alice), merchant: shop.slug, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '', min_spend_amount: '30', min_spend_currency: 'gbp', regions: 'GB' } });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ });
+
+ await check('the codes are one pick block: a chip per list, the first picked, every list in the page', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200, r.text);
+ const page = r.text;
+ const start = page.indexOf('<div class="pick" id="pick-codes">');
+ assert.ok(start > 0, 'the pick block is on the home');
+ const block = page.slice(start, page.indexOf('<section class="cp-sec" id="shops"'));
+ assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Codes">/);
+ const chips = [...block.matchAll(/<input type="radio" name="pick-codes" id="pk-codes-([a-z-]+)" value="([a-z-]+)"( checked)?><label for="pk-codes-[a-z-]+">([^<]+)<\/label>/g)];
+ assert.ok(chips.length >= 1, `a chip per list (${chips.length})`);
+ assert.deepStrictEqual(chips.filter((m) => m[3]).map((m) => m[1]), [chips[0][1]], 'the first list is picked');
+ // The lists that have something to show stay in the page, each in its own pane.
+ for (const id of chips.map((m) => m[1])) {
+ assert.ok(block.includes(`id="${id}"`), `the pane of ${id} is in the page`);
+ }
+ assert.ok(block.includes('VEG5'), 'the code is on the page');
+ });
+
+ await check('the CSS guard: the chips are hidden and the lists all show until 640 px and where :has() is understood', async () => {
+ const r = await t.get('/');
+ const page = r.text;
+ assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-codes-cp-working:checked\) #cp-working\{display:block\}/, 'the rule that shows the picked list');
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'coupons.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 lists 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.get('/');
+ const page = r.text;
+ for (const id of ['companion', 'faq', 'network', 'shipped']) {
+ assert.ok(page.includes(`<details class="cp-fold cp-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, /<h2 id="cp-companion-h">The OpenVibe Companion<\/h2>/);
+ assert.match(page, /<h2 id="cp-faq-h">Questions and answers<\/h2>/);
+ assert.match(page, /<h2 id="cp-network-h">Part of OpenVibe<\/h2>/);
+ assert.match(page, /<h2 id="cp-shipped-h">Recently shipped on OpenVibe.Coupons<\/h2>/);
+ assert.match(page, /<summary>Do these codes work\?<\/summary>/, 'the questions are still on the page');
+ });
+ } finally {
+ await done(t);
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 43, // 42.3 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)
+ htmlRawKB: 46, // 45.1 with the short home (2026-10-30: the code lists are one pick block and the rare parts are folds); 42.3 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)
htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)
htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)
jsFiles: 6, // 6 with the OpenVibe Kit's kit-web.js (Shared 4.20.1, 2026-10-28); 5 (openvibe-shared 2.9 shell: web-runtime.js)
jsFiles: 6, // 6 with the OpenVibe Kit's kit-web.js (Shared 4.20.1, 2026-10-28); 5 (openvibe-shared 2.9 shell: web-runtime.js)
jsRawKB: 334, // 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 327.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); 319.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 312.4 openvibe-shared 4.29.2 (2026-10-28): Notifications 2.0's toast layer (held while a menu is open, grouping, priority, System cards) and the menu's results view; 307.6 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; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 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; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js) // raised 316 -> 325 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
jsRawKB: 334, // 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 327.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); 319.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 312.4 openvibe-shared 4.29.2 (2026-10-28): Notifications 2.0's toast layer (held while a menu is open, grouping, priority, System cards) and the menu's results view; 307.6 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; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 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; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js) // raised 316 -> 325 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
jsBrotliKB: 83, // 82.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 80.9 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); 79.3 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 76.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; 73.5 Shared 4.20.1 (kit-web.js); 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; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js) // raised 78 -> 80 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
jsBrotliKB: 83, // 82.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 80.9 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); 79.3 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 76.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; 73.5 Shared 4.20.1 (kit-web.js); 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; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js) // raised 78 -> 80 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
- cssRawKB: 72, // 70.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); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
- cssBrotliKB: 14.8, // 14.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); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
+ cssRawKB: 74, // 73.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 70.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); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
+ cssBrotliKB: 15.4, // 15.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 14.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); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};