openvibers/openvibe.deals Commits

Deals home: show every section on a phone; chips are links (#186)

OP OpenVibers committed 11 days ago · b47680d26f · 5 files, +45 −76

Changes

ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex fc8781a..084af0f 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -190,7 +190,6 @@ code { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hm-search { flex-wrap: wrap; } .hm-search button { width: 100%; } .hm-stats b { font-size: 1.3rem; } .hm-stats a { padding: 12px; } }
@media (prefers-reduced-motion: reduce) { .hm-card, .hm-stats a { transition: none; } }
.hm-card .price-none { font-size: .88rem; font-weight: 500; }
--/* A phone shows four cards a section (all eight stay in the page for crawlers and the "see all" link has the rest). */
/* Phones (2026-10-29): a taste and a door. Each deal list and the campaigns are one row that scrolls sideways (every card
is still there), the clouds show ten each (all are a link away), the figures are one strip. */
.hm-fold { margin: 48px 0 0; font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
@@@ -217,30 +216,22 @@ code { overflow-wrap: anywhere; }
.cf-campaign a { display: inline-block; min-height: 44px; }
}
--/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page's sections are one at a time, picked
-- with a row of chips. Radio inputs and :has(), no script: the chips and the one-pane rule live under 640 px and only
-- where :has() is understood, so a wider screen, or an older browser, shows every pane and no chips. Every pane stays
-- in the page for crawlers. */
--.pick-head { display: none; }
--.pick-row { display: none; }
--@supports selector(:has(*)) { @media (max-width: 640px) {
++/* Pick: on a phone every pane is shown, stacked, each with its own heading; the chips are a row of links that
++ jump to a pane (the owner's rule, 2026-10-31: never hide content to shorten). A wide screen shows the panes as
++ before, with no chips. */
++.pick-head, .pick-row { display: none; }
++@media (max-width: 640px) {
.pick-bar { margin-top: 30px; }
.pick-head { display: block; }
.pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px, 2.4vw, 28px)/1.2 var(--ov-font, 'Inter', system-ui, sans-serif); letter-spacing: -.02em; }
.pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
.pick-row::-webkit-scrollbar { display: none; }
-- .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
-- .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
-- .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
-- .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
-- .pick .pick-pane { display: none; }
-- /* A link that points at a pane opens that pane on a phone too. */
-- .pick .pick-pane:target { display: block !important; }
-- .pick:has(.pick-pane:target) .pick-pane:not(:target) { display: none !important; }
-- /* The chip names the pane, so the pane's own heading gives way to it; the link to more stays. */
-- .pick .pick-pane .hm-head h2 { display: none; }
-- .pick .pick-pane .hm-head { justify-content: flex-end; }
--} }
++ .pick-chip { 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; text-decoration: none; }
++ .pick-chip:hover, .pick-chip:focus-visible { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }
++ .pick-chip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++ .pick .pick-pane { scroll-margin-top: 72px; }
++ .pick .pick-pane + .pick-pane { margin-top: 26px; }
++}
/* 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. It reuses the .hm-fold card the page already has. */
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2821247..4ea0152 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -222,8 +222,6 @@ function createPages(ctx) {
]),
bodyClass: 'home',
body: home.homePage(data, urls),
-- // The rule that shows the picked pane of the home's pick blocks: one per pane, written for this page.
-- inlineStyles: [home.paneRules(data)],
};
}
ddiff --git a/server/render/home.js b/server/render/home.js
iindex 9beaa36..21c92c5 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -14,7 +14,7 @@ const showcase = require('openvibe-shared/showcase');
const { ago, when, priceText } = require('./views');
const { remindLink } = require('./remind');
const { campaignCard } = require('../http/crowdfunding');
--const { pickBlock, pickRules, foldBlock } = require('./short');
++const { pickBlock, foldBlock } = require('./short');
const count = (n) => Number(n).toLocaleString('en-US');
const safeHref = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
@@@ -136,8 +136,6 @@ function homePage(d, urls) {
let under = null;
try { under = d.underLimit ? new Intl.NumberFormat('en-US', { style: 'currency', currency: d.underLimit.currency, maximumFractionDigits: 0 }).format(d.underLimit.amount) : null; } catch { under = null; }
-- // The home's two pick blocks, one at a time on a phone (the owner's short-pages rule, 2026-10-30): the chips pick
-- // one of the sections and every section stays in the page for crawlers.
const dealPanes = [
{ id: 'd-picks', label: 'Picks', html: section({ id: 'hm-picks', title: 'Editors\' picks', lede: 'Deals DealNews\' editors marked as their picks, newest first.', more: { href: '/picks', label: 'All picks' }, body: cards(d.picks, urls) }) },
{ id: 'd-hot', label: 'Hot', html: section({ id: 'hm-hot', title: 'Hot right now', lede: 'Voted up by people on OpenVibe, hottest first.', more: { href: '/hot', label: 'All hot deals' }, body: cards(d.upvoted, urls) }) },
@@@ -176,15 +174,4 @@ ${raw(foldBlock({
}))}`;
}
--/**
-- * The rules that show the picked pane of the home's two pick blocks: one per pane, written for this page.
-- */
--function paneRules(d) {
-- const ids = (panes) => panes.filter((p) => p.html).map((p) => p.id);
-- const under = (() => { try { return d.underLimit ? new Intl.NumberFormat('en-US', { style: 'currency', currency: d.underLimit.currency, maximumFractionDigits: 0 }).format(d.underLimit.amount) : null; } catch { return null; } })();
-- const dealIds = ['d-picks', 'd-hot', 'd-new', 'd-ending', under ? 'd-under' : null].filter(Boolean);
-- const browseIds = ['d-categories', 'd-shops', 'd-crowdfunding'];
-- return `@supports selector(:has(*)){@media (max-width:640px){${pickRules('deals', dealIds)}${pickRules('browse', browseIds)}}}`;
--}
--
--module.exports = { homePage, paneRules, faqItems, faqJsonLd, card, money };
++module.exports = { homePage, faqItems, faqJsonLd, card, money };
ddiff --git a/server/render/short.js b/server/render/short.js
iindex 086d87b..e7c052e 100644
---- a/server/render/short.js
++++ b/server/render/short.js
@@@ -1,44 +1,32 @@
'use strict';
/**
-- * The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
++ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): every block is shown, stacked,
++ * each with its own heading. The chips are links that jump to a block.
*
-- * 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.
++ * pickBlock() several blocks of the same kind, all shown; the chips are a row of links to them, never a picker.
* 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/deals.css; the rule that shows the picked block is written for the page,
-- * because there is one per block.
++ * The CSS for both lives in public/css/deals.css.
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/**
-- * 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.
++ * A pick block. The panes are the page's own blocks, all shown, each with its own heading; the chips are links that
++ * jump to them.
*
* 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.
++ // A pane with nothing to show is left out, so the chips and the panes 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 chips = panes.map((p) => `<a class="pick-chip" href="#${esc(p.id)}">${esc(p.label)}</a>`).join('');
++ // The pane carries the id the page already links to (an anchor in the hero, for instance), so the chip and any
++ // link to it land on that block.
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('');
++ return `<div class="pick" id="pick-${esc(key)}"><div class="pick-bar"><div class="pick-head"><h2>${esc(head || label)}</h2></div><nav class="pick-row" aria-label="${esc(label)}">${chips}</nav></div><div class="pick-body">${body}</div></div>`;
}
/**
@@@ -55,4 +43,4 @@ 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 };
++module.exports = { pickBlock, foldBlock, foldScript };
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 754e434..cad3248 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -1,8 +1,8 @@
'use strict';
/**
-- * The home on a phone (the owner's short-pages rule, 2026-10-30): the home's sections are two pick blocks, 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 section stays in the page for crawlers; a wider screen shows them all and no chips.
++ * The home on a phone (the owner's rule, 2026-10-31: never hide content to shorten): the home's sections are two pick
++ * blocks, every section is shown, stacked, with its own heading, and the chips are a row of links that jump to a
++ * section. The parts a reader rarely needs are folds.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -20,7 +20,7 @@ const { boot, check, done } = require('./helpers/boot');
await t.ctx.store.db.prepare("UPDATE deal_offers SET category = 'Kitchen', seller = 'Coffee Shop', staff_pick = true WHERE id = (SELECT id FROM deal_offers ORDER BY seq DESC LIMIT 1)").run();
await t.ctx.store.db.prepare("UPDATE deal_offers SET category = 'Kitchen', seller = 'Coffee Shop' WHERE id = (SELECT id FROM deal_offers ORDER BY seq ASC LIMIT 1)").run();
-- await check('the home is two pick blocks: a chip per section, the first picked, every section in the page', async () => {
++ await check('the home is two pick blocks: a link per section, every section in the page and showing', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
const page = r.text;
@@@ -29,12 +29,13 @@ const { boot, check, done } = require('./helpers/boot');
const next = page.indexOf('<div class="pick" id="pick-', start + 10);
const block = page.slice(start, next > 0 ? next : start + 20000);
assert.ok(block.startsWith(`<div class="pick" id="pick-${key}">`), `the ${key} block is on the home`);
-- const chips = [...block.matchAll(/<input type="radio" name="pick-[a-z]+" id="pk-[a-z]+-([a-z-]+)" value="\1"( checked)?><label for="pk-[a-z]+-\1">([^<]+)<\/label>/g)];
-- assert.ok(chips.length >= 1, `a chip per section (${chips.length})`);
-- assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first section is picked');
-- // Every section stays in the page, with the id its chip picks.
++ assert.ok(!/<input type="radio"/.test(block), `no radio in the ${key} block`);
++ assert.match(block, /<nav class="pick-row" aria-label="[^"]+">/, `the ${key} chips are a nav of links`);
++ const chips = [...block.matchAll(/<a class="pick-chip" href="#([a-z-]+)">[^<]+<\/a>/g)];
++ assert.ok(chips.length >= 1, `a link per section (${chips.length})`);
++ // Every section is in the page, and every link points at one of them.
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 section is in the page, one per chip');
++ assert.deepStrictEqual(panes, chips.map((m) => m[1]), 'every section is in the page, one per link');
}
// Nothing was removed: every section of the home is still in the page.
for (const title of ['Editors', 'Just in', 'Browse by category', 'Shop by store', 'Questions and answers']) {
@@@ -42,15 +43,19 @@ const { boot, check, done } = require('./helpers/boot');
}
});
-- await check('the CSS guard: the chips are hidden and the sections all show until 640 px and where :has() is understood', async () => {
++ await check('the CSS shows every pane on a phone; the chips are links, not a picker', async () => {
const r = await t.get('/');
const page = r.text;
-- assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick:has\(#pk-deals-d-picks:checked\) #d-picks\{display:block\}/, 'the rule that shows the picked section');
++ assert.ok(!page.includes('.pick:has('), 'no inline rule picks a section');
++ assert.ok(!page.includes('pk-deals'), 'no radio ids on the page');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'deals.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 sections are hidden on a phone');
++ assert.match(css, /\.pick-head, \.pick-row \{ display: none; \}/, 'the chips are hidden by default');
++ assert.match(css, /@media \(max-width: 640px\) \{/, 'the phone rules are under 640 px');
++ assert.match(css, /\.pick-row \{ display: flex/, 'the chips are a row on a phone');
++ assert.match(css, /\.pick-chip \{[^}]*min-height: 44px/, 'a chip is a 44 px link target');
++ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*\{[^}]*display:\s*none/.test(css), 'no rule hides a pane');
++ assert.ok(!/:target/.test(css), 'no :target pane rule');
++ assert.ok(!/\.pick[^{}]*\.pick-pane[^{}]*h2[^{}]*\{[^}]*display:\s*none/.test(css), 'a pane keeps its heading on a phone');
});
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {