openvibers/openvibe.shared Commits
4.177.0: the offer card (openvibe-shared/offer-card), designed (#378)
Changes
@@@ -1,5 +1,15 @@# Changelog++## 4.177.0 — 2026-11-09++++- **`openvibe-shared/offer-card`: one offer from the network's shops** (`offer-card.js`, styles in `kit-data.css`). It++ shows the title and the shop, the all-in price formatted as money (`$90.00`, `€50.00`) with what it includes ("Free++ shipping", "+ $4.99 shipping", "Shipping not stated"), and how fresh the price is ("Checked 3 hours ago"). Nothing++ unknown is invented: no price or check time says so. A stale offer says "may have changed". Only http(s) links are++ kept. On a phone the price goes under the title.++- **A section's line sits under its title** (`showcase.css`). The line under a showcase section's heading was always++ indented to clear a heading icon, so a heading without one (Blog's "Latest from the OpenVibe blog") had its line++ pushed right. It is indented only when the heading has its icon.## 4.176.0 — 2026-11-09- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.176.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.177.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.176.0",++ "version": "4.177.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -275,3 +275,20 @@ a.ovk-tool-crumb:hover { color: var(--ovk-text); }.ovk-family-full { display: inline; }}@media (pointer: coarse) { .ovk-try > a { min-height: 36px; } }++++/* An offer (offer-card.js, Opus 11-09): title and shop left, the all-in price right with what it includes, one quiet++ freshness line. Stale offers fade a little and say so. */++.ov-offer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: start; padding: 14px 16px; border: 1px solid var(--ovk-line, var(--border, #1f2d47)); border-radius: 14px; background: var(--ovk-card, var(--bg-card, #101828)); min-width: 0; }++.ov-offer-main { min-width: 0; }++.ov-offer-title { margin: 0; font-size: 1rem; line-height: 1.3; overflow-wrap: anywhere; }++.ov-offer-title a { color: var(--ovk-text, var(--text-primary, #e6edf7)); text-decoration: none; }++.ov-offer-title a:hover { text-decoration: underline; text-underline-offset: 3px; }++.ov-offer-facts { margin: 4px 0 0; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .88rem; overflow-wrap: anywhere; }++.ov-offer-price { display: grid; justify-items: end; text-align: right; }++.ov-offer-price b { font-size: 1.25rem; font-variant-numeric: tabular-nums; color: var(--ovk-text, var(--text-primary, #e6edf7)); }++.ov-offer-price b.is-unknown { font-size: .95rem; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); }++.ov-offer-price small { color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .8rem; }++.ov-offer-fresh { grid-column: 1 / -1; margin: 6px 0 0; color: var(--ovk-faint, var(--text-muted, #7386a3)); font-size: .78rem; }++.ov-offer.is-stale { opacity: .85; }++.ov-offer-stale { color: var(--warning, #f59f00); }++@media (max-width: 420px) { .ov-offer { grid-template-columns: minmax(0, 1fr); } .ov-offer-price { justify-items: start; text-align: left; } }nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * One offer from the network's shops (open.shoes, Deals, Coupons): what it is, where, the all-in price and how fresh++ * that price is. Unknown shipping and an unknown check time stay unknown; the card never invents them. Opus's design++ * (11-09): the title and the shop on the left, the price large on the right with what it includes under it, one quiet++ * freshness line; styles in kit-data.css (`.ov-offer`), so a site that loads the kit gets the look.++ *++ * offerCard({ title, url, store, price | all_in, currency, shipping, size, condition, checked_at, stale })++ */++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++function money(amount, currency) {++ const code = /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD';++ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(amount); } catch { return `${code} ${amount.toFixed(2)}`; }++}++++function checkedText(iso, now = Date.now()) {++ const t = Date.parse(String(iso || ''));++ if (!Number.isFinite(t)) return 'Check time not known yet';++ const mins = Math.max(0, Math.round((now - t) / 60000));++ if (mins < 2) return 'Checked just now';++ if (mins < 60) return `Checked ${mins} minutes ago`;++ const hours = Math.round(mins / 60);++ if (hours < 24) return `Checked ${hours} hour${hours === 1 ? '' : 's'} ago`;++ return `Checked ${new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}`;++}++++function offerCard(offer = {}, { now = Date.now() } = {}) {++ const href = typeof offer.url === 'string' && /^https?:\/\//i.test(offer.url) ? offer.url : '';++ const allIn = Number(offer.all_in ?? offer.price);++ const price = Number.isFinite(allIn) ? money(allIn, offer.currency) : '';++ const shipping = offer.shipping == null || offer.shipping === '' ? 'Shipping not stated'++ : Number(offer.shipping) === 0 ? 'Free shipping' : `+ ${money(Number(offer.shipping), offer.currency)} shipping`;++ const title = esc(offer.title || offer.store || 'Offer');++ const facts = [offer.store ? esc(offer.store) : 'Shop not known', offer.size ? `Size ${esc(offer.size)}` : '', offer.condition ? esc(offer.condition) : ''].filter(Boolean).join(' · ');++ return `<article class="ov-offer${offer.stale ? ' is-stale' : ''}" data-offer-card>`++ + `<div class="ov-offer-main"><h3 class="ov-offer-title">${href ? `<a href="${esc(href)}" rel="nofollow noopener">${title}</a>` : title}</h3><p class="ov-offer-facts">${facts}</p></div>`++ + `<div class="ov-offer-price">${price ? `<b>${esc(price)}</b>` : '<b class="is-unknown">Price not known yet</b>'}<small>${esc(shipping)}</small></div>`++ + `<p class="ov-offer-fresh">${esc(checkedText(offer.checked_at, now))}${offer.stale ? ' · <span class="ov-offer-stale">may have changed</span>' : ''}</p>`++ + '</article>';++}++++module.exports = { offerCard };@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.176.0",++ "version": "4.177.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.176.0",++ "version": "4.177.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.176.0",++ "version": "4.177.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -8,6 +8,7 @@},"exports": {".": "./index.js",++ "./offer-card": "./offer-card.js","./account-switcher": "./account-switcher.js","./analytics": "./analytics/index.js","./analytics/event": "./analytics/event.js",@@@ -57,7 +57,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}.sc-h-ic svg{display:block}--.sc-sec>h2+.sc-lede{margin-left:48px}++.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}@@@ -509,7 +509,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}.sc-h-ic svg{display:block}--.sc-sec>h2+.sc-lede{margin-left:48px}++.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++++const assert = require('assert');++const { offerCard } = require('../offer-card');++++const now = Date.parse('2026-11-09T12:00:00Z');++const html = offerCard({ title: '<Boot>', url: 'https://store.example/item', store: 'Shop', price: 90, shipping: null, currency: 'USD', size: '9', checked_at: '2026-11-09T09:00:00Z' }, { now });++assert.ok(html.includes('<Boot>'), 'the title is escaped');++assert.ok(html.includes('Shipping not stated'), 'unknown shipping stays unknown');++assert.ok(html.includes('$90.00'), 'the price is formatted as money');++assert.ok(html.includes('Size 9'));++assert.ok(html.includes('Checked 3 hours ago'));++assert.ok(offerCard({ price: 50, shipping: 0, currency: 'EUR' }).includes('€50.00') && offerCard({ price: 50, shipping: 0 }).includes('Free shipping'));++assert.ok(offerCard({ title: 'X' }).includes('Price not known yet') && offerCard({ title: 'X' }).includes('Check time not known yet'), 'nothing is invented');++assert.ok(offerCard({ title: 'X', price: 1, stale: true }).includes('may have changed'));++assert.ok(!offerCard({ url: 'javascript:alert(1)', title: 'Unsafe' }).includes('href="javascript:'), 'only http(s) links');++assert.ok(!offerCard({ title: 'X', price: 1, currency: '"><script>' }).includes('<script>'), 'a bad currency code falls back');++console.log('offer card: all checks passed');
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
# Changelog
# Changelog
+## 4.177.0 — 2026-11-09
+
+- **`openvibe-shared/offer-card`: one offer from the network's shops** (`offer-card.js`, styles in `kit-data.css`). It
+ shows the title and the shop, the all-in price formatted as money (`$90.00`, `€50.00`) with what it includes ("Free
+ shipping", "+ $4.99 shipping", "Shipping not stated"), and how fresh the price is ("Checked 3 hours ago"). Nothing
+ unknown is invented: no price or check time says so. A stale offer says "may have changed". Only http(s) links are
+ kept. On a phone the price goes under the title.
+- **A section's line sits under its title** (`showcase.css`). The line under a showcase section's heading was always
+ indented to clear a heading icon, so a heading without one (Blog's "Latest from the OpenVibe blog") had its line
+ pushed right. It is indented only when the heading has its icon.
## 4.176.0 — 2026-11-09
## 4.176.0 — 2026-11-09
- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any
- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.176.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.177.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.176.0",
+ "version": "4.177.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -275,3 +275,20 @@ a.ovk-tool-crumb:hover { color: var(--ovk-text); }
@@ -275,3 +275,20 @@ a.ovk-tool-crumb:hover { color: var(--ovk-text); }
.ovk-family-full { display: inline; }
.ovk-family-full { display: inline; }
}
}
@media (pointer: coarse) { .ovk-try > a { min-height: 36px; } }
@media (pointer: coarse) { .ovk-try > a { min-height: 36px; } }
+
+/* An offer (offer-card.js, Opus 11-09): title and shop left, the all-in price right with what it includes, one quiet
+ freshness line. Stale offers fade a little and say so. */
+.ov-offer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: start; padding: 14px 16px; border: 1px solid var(--ovk-line, var(--border, #1f2d47)); border-radius: 14px; background: var(--ovk-card, var(--bg-card, #101828)); min-width: 0; }
+.ov-offer-main { min-width: 0; }
+.ov-offer-title { margin: 0; font-size: 1rem; line-height: 1.3; overflow-wrap: anywhere; }
+.ov-offer-title a { color: var(--ovk-text, var(--text-primary, #e6edf7)); text-decoration: none; }
+.ov-offer-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.ov-offer-facts { margin: 4px 0 0; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .88rem; overflow-wrap: anywhere; }
+.ov-offer-price { display: grid; justify-items: end; text-align: right; }
+.ov-offer-price b { font-size: 1.25rem; font-variant-numeric: tabular-nums; color: var(--ovk-text, var(--text-primary, #e6edf7)); }
+.ov-offer-price b.is-unknown { font-size: .95rem; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); }
+.ov-offer-price small { color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .8rem; }
+.ov-offer-fresh { grid-column: 1 / -1; margin: 6px 0 0; color: var(--ovk-faint, var(--text-muted, #7386a3)); font-size: .78rem; }
+.ov-offer.is-stale { opacity: .85; }
+.ov-offer-stale { color: var(--warning, #f59f00); }
+@media (max-width: 420px) { .ov-offer { grid-template-columns: minmax(0, 1fr); } .ov-offer-price { justify-items: start; text-align: left; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * One offer from the network's shops (open.shoes, Deals, Coupons): what it is, where, the all-in price and how fresh
+ * that price is. Unknown shipping and an unknown check time stay unknown; the card never invents them. Opus's design
+ * (11-09): the title and the shop on the left, the price large on the right with what it includes under it, one quiet
+ * freshness line; styles in kit-data.css (`.ov-offer`), so a site that loads the kit gets the look.
+ *
+ * offerCard({ title, url, store, price | all_in, currency, shipping, size, condition, checked_at, stale })
+ */
+const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+function money(amount, currency) {
+ const code = /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD';
+ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(amount); } catch { return `${code} ${amount.toFixed(2)}`; }
+}
+
+function checkedText(iso, now = Date.now()) {
+ const t = Date.parse(String(iso || ''));
+ if (!Number.isFinite(t)) return 'Check time not known yet';
+ const mins = Math.max(0, Math.round((now - t) / 60000));
+ if (mins < 2) return 'Checked just now';
+ if (mins < 60) return `Checked ${mins} minutes ago`;
+ const hours = Math.round(mins / 60);
+ if (hours < 24) return `Checked ${hours} hour${hours === 1 ? '' : 's'} ago`;
+ return `Checked ${new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}`;
+}
+
+function offerCard(offer = {}, { now = Date.now() } = {}) {
+ const href = typeof offer.url === 'string' && /^https?:\/\//i.test(offer.url) ? offer.url : '';
+ const allIn = Number(offer.all_in ?? offer.price);
+ const price = Number.isFinite(allIn) ? money(allIn, offer.currency) : '';
+ const shipping = offer.shipping == null || offer.shipping === '' ? 'Shipping not stated'
+ : Number(offer.shipping) === 0 ? 'Free shipping' : `+ ${money(Number(offer.shipping), offer.currency)} shipping`;
+ const title = esc(offer.title || offer.store || 'Offer');
+ const facts = [offer.store ? esc(offer.store) : 'Shop not known', offer.size ? `Size ${esc(offer.size)}` : '', offer.condition ? esc(offer.condition) : ''].filter(Boolean).join(' · ');
+ return `<article class="ov-offer${offer.stale ? ' is-stale' : ''}" data-offer-card>`
+ + `<div class="ov-offer-main"><h3 class="ov-offer-title">${href ? `<a href="${esc(href)}" rel="nofollow noopener">${title}</a>` : title}</h3><p class="ov-offer-facts">${facts}</p></div>`
+ + `<div class="ov-offer-price">${price ? `<b>${esc(price)}</b>` : '<b class="is-unknown">Price not known yet</b>'}<small>${esc(shipping)}</small></div>`
+ + `<p class="ov-offer-fresh">${esc(checkedText(offer.checked_at, now))}${offer.stale ? ' · <span class="ov-offer-stale">may have changed</span>' : ''}</p>`
+ + '</article>';
+}
+
+module.exports = { offerCard };
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.176.0",
+ "version": "4.177.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.176.0",
+ "version": "4.177.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.176.0",
+ "version": "4.177.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
},
},
"exports": {
"exports": {
".": "./index.js",
".": "./index.js",
+ "./offer-card": "./offer-card.js",
"./account-switcher": "./account-switcher.js",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics/index.js",
"./analytics": "./analytics/index.js",
"./analytics/event": "./analytics/event.js",
"./analytics/event": "./analytics/event.js",
@@ -57,7 +57,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
@@ -57,7 +57,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic svg{display:block}
.sc-h-ic svg{display:block}
-.sc-sec>h2+.sc-lede{margin-left:48px}
+.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
@@ -509,7 +509,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
@@ -509,7 +509,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic svg{display:block}
.sc-h-ic svg{display:block}
-.sc-sec>h2+.sc-lede{margin-left:48px}
+.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+
+const assert = require('assert');
+const { offerCard } = require('../offer-card');
+
+const now = Date.parse('2026-11-09T12:00:00Z');
+const html = offerCard({ title: '<Boot>', url: 'https://store.example/item', store: 'Shop', price: 90, shipping: null, currency: 'USD', size: '9', checked_at: '2026-11-09T09:00:00Z' }, { now });
+assert.ok(html.includes('<Boot>'), 'the title is escaped');
+assert.ok(html.includes('Shipping not stated'), 'unknown shipping stays unknown');
+assert.ok(html.includes('$90.00'), 'the price is formatted as money');
+assert.ok(html.includes('Size 9'));
+assert.ok(html.includes('Checked 3 hours ago'));
+assert.ok(offerCard({ price: 50, shipping: 0, currency: 'EUR' }).includes('€50.00') && offerCard({ price: 50, shipping: 0 }).includes('Free shipping'));
+assert.ok(offerCard({ title: 'X' }).includes('Price not known yet') && offerCard({ title: 'X' }).includes('Check time not known yet'), 'nothing is invented');
+assert.ok(offerCard({ title: 'X', price: 1, stale: true }).includes('may have changed'));
+assert.ok(!offerCard({ url: 'javascript:alert(1)', title: 'Unsafe' }).includes('href="javascript:'), 'only http(s) links');
+assert.ok(!offerCard({ title: 'X', price: 1, currency: '"><script>' }).includes('<script>'), 'a bad currency code falls back');
+console.log('offer card: all checks passed');