openvibers/openvibe.deals Commits

Deals: deal cards and one page gutter (#19)

OP OpenVibers committed 8 Oct 2026 · 038bc78df9 · 3 files, +30 −12

* Deals: deal cards (vote pill, title, price line, attribution), a quiet 'not stated' for headline-only sources, the network font, and the hero, cards and list on one 1080 px gutter

* Deals: CSS budget 21 KB (19.0 with the deal cards)

Changes

ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex 5732d02..dd9b1af 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -1,7 +1,7 @@
/* OpenVibe.Deals — readable, theme-token driven (openvibe-shared presets). No JavaScript needed. */
:root { color-scheme: dark; }
* { box-sizing: border-box; }
--body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 17px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 17px/1.65 var(--ov-font, 'Inter', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); }
a { color: var(--accent-light, #60a5fa); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
.skip { position: absolute; left: -9999px; }
@@@ -47,15 +47,26 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
.tabs form.search { margin-left: auto; display: flex; gap: 6px; }
.tabs form.search input { width: 200px; }
.tabs form.search button { margin: 0; }
--.deal-list { list-style: none; padding: 0; }
--.deal-item { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border, #1f2d47); }
--.deal-item .score { min-width: 48px; text-align: center; font-weight: 700; font-size: 1.1rem; padding-top: 4px; }
--.deal-body { min-width: 0; flex: 1; }
--.deal-title { margin: 0 0 4px; font-size: 1.15rem; overflow-wrap: anywhere; }
++.deal-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 10px; }
++.deal-item { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
++.deal-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.deal-item .score { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 56px; border-radius: 12px; font-weight: 800; font-size: 1rem; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.deal-item .score span { font-size: .7rem; line-height: 1; color: var(--text-muted, #7386a3); }
++.deal-item .score.up { color: var(--success, #22c55e); border-color: color-mix(in srgb, var(--success, #22c55e) 40%, transparent); }
++.deal-item .score.up span { color: var(--success, #22c55e); }
++.deal-item .score.down { color: var(--warning, #f59e0b); }
++.deal-body { min-width: 0; }
++.deal-title { margin: 0 0 6px; font-size: 1.05rem; line-height: 1.35; overflow-wrap: anywhere; }
.deal-title a { color: var(--text-primary); text-decoration: none; }
--.deal-title a:hover { text-decoration: underline; }
--.price-line { margin: 4px 0; }
--.price { font-size: 1.1rem; }
++.deal-title a:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.price-line { margin: 0 0 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; font-size: .9rem; }
++.price { font-size: 1.05rem; color: var(--warning, #f59e0b); }
++.price.price-none { font-size: .8rem; font-weight: 500; color: var(--text-muted, #7386a3); }
++.price-line .as-of { font-size: .8rem; }
++.attribution { margin: 0; font-size: .82rem; color: var(--text-muted, #7386a3); }
++.deal-item .meta { margin: 2px 0 0; font-size: .82rem; }
++@media (max-width: 600px) { .deal-item { grid-template-columns: 44px minmax(0, 1fr); padding: 12px; } .deal-item .score { height: 48px; } }
++@media (prefers-reduced-motion: reduce) { .deal-item { transition: none; } }
.badge.fresh { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
.stale-notice { border-color: var(--warning, #f59e0b); }
@@@ -71,3 +82,8 @@ tr.removed { opacity: .6; }
details.card summary { cursor: pointer; font-weight: 600; }
code { overflow-wrap: anywhere; }
@media (max-width: 600px) { .tabs form.search { margin-left: 0; width: 100%; } .tabs form.search input { width: 100%; } .deal-item .score { min-width: 36px; } }
++
++/* The front page's showcase sections share the page's gutter, so the hero, the cards and the list line up. */
++.page { max-width: 1080px; }
++.account-bar { max-width: 1080px; }
++.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
ddiff --git a/server/render/views.js b/server/render/views.js
iindex b2820d4..e3908df 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -67,7 +67,9 @@ function attributionLine(a) {
/** The one-line "latest observation" block used on cards and pages. */
function latestLine(v) {
const o = v.latest;
-- return html`<p class="price-line"><strong class="price" data-price="${o && o.price != null ? o.price : ''}">${priceText(o)}</strong>
++ // A price nobody stated is said quietly: a headline-only source (DealNews' feed) states its price in the title.
++ const stated = o && o.price != null;
++ return html`<p class="price-line"><strong class="price${stated ? '' : ' price-none'}" data-price="${stated ? o.price : ''}">${priceText(o)}</strong>
${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : ''}
${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.availability]}</span>` : ''}
${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;
@@@ -77,7 +79,7 @@ function offerCard(v, urls) {
const r = v.root;
const score = v.tally.up - v.tally.down;
return html`<li class="deal-item">
--<div class="score" title="${v.tally.up} up, ${v.tally.down} down">${score > 0 ? `+${score}` : score}</div>
++<div class="score${score > 0 ? ' up' : score < 0 ? ' down' : ''}" title="${v.tally.up} up, ${v.tally.down} down"><span aria-hidden="true">▲</span>${score > 0 ? `+${score}` : score}</div>
<div class="deal-body">
<h2 class="deal-title"><a href="${urls.offerPath(r)}">${r.title}</a> ${statusBadge(r)}</h2>
${latestLine(v)}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex bd9297b..3237149 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,7 +18,7 @@ const BUDGETS = {
jsRawKB: 250, // 239.1 (212.2 before web-runtime.js)
jsBrotliKB: 60, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (deals.css, openvibe-shared showcase.css)
-- cssRawKB: 18.5, // 17.1 (5.6 before showcase.css)
++ cssRawKB: 21, // 19.0 with the deal cards
cssBrotliKB: 4.6, // 4.1 (1.5 before showcase.css)
externalFiles: 1, // 0
};