openvibers/openvibe.shared Commits
4.44.0: showcase.fold and the .sc-row phone row (#184)
Changes
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.44.0 — 2026-10-29++++- **`showcase.fold({ id, title, hint, html, open })`** folds read-once material (how it works, the code samples, what++ every page shows) under its own heading. The heading stays visible in the summary and keeps its id, so links still++ land and the outline does not change; other showcase sections nest inside and drop their own width and padding. It++ takes the sections' width and font, which the eleven local folds written on 2026-10-29 had to repeat (three of them++ missed: one rendered in the browser's serif, edge to edge).++- **`.sc-row`** on any list makes it one sideways row on a phone (scroll snap, the next card peeking, every item still in++ the HTML) and leaves it as the page made it on wider screens. `--sc-row-w` sets the card width (82% by default). It++ wins over a page's own grid and never inherits a `flex-wrap` (the video home's figures wrapped into 2×2).++## 4.43.0 — 2026-10-29- **A theme for one site.** `shell.page` writes `<html data-ov-service="<site>">` (the page's service id, when it is a short@@@ -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.43.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.44.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.43.0",++ "version": "4.44.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)",@@@ -494,6 +494,20 @@ that fills its rows; four tiles are four across on a phone, two by two from 600Don't pass the site's own name as the eyebrow: it is left out, because the navbar's brand already says it. Use theeyebrow for something worth reading first ("Free, nothing to install"), with `{ icon, text }` for an icon.++### Folds and phone rows (4.44.0)++++A product home is "a taste and a door": on a phone it shows a little of everything and links to the rest. Two pieces++do most of that:++++- `showcase.fold({ title: 'How it works', hint: 'four steps', html: showcase.steps({…}) + showcase.code({…}) })` for++ what is read once. The heading stays visible (and its id with it); the steps, facts or code open on a tap.++- `class="sc-row"` on a card list (`<ul class="my-cards sc-row">`) for anything with more than four cards: on a phone++ it is one sideways row with the next card peeking, so twenty listings cost one card's height and none is dropped.++ Small tiles set `style="--sc-row-w:46%"`.++++Lists that are text (questions, chips, table rows) are cut on phones instead, with `nth-child` and a link to the whole++list; a closing call that repeats the hero, or a "find your way around" grid that repeats the nav, is left out.++## Balances`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.43.0",++ "version": "4.44.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.43.0",++ "version": "4.44.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.43.0",++ "version": "4.44.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -135,6 +135,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}++.sc-fold>summary::-webkit-details-marker{display:none}++.sc-fold>summary::after{content:'';position:absolute;right:22px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}++.sc-fold[open]>summary::after{transform:rotate(-135deg);margin-top:-2px}++.sc-fold>summary:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:-2px;border-radius:16px}++.sc-fold>summary h2{margin:0;font-size:clamp(18px,2.2vw,22px)}++.sc-fold-hint{color:var(--text-muted,#7386a3);font-size:14px;font-weight:600;text-align:right}++.sc-fold-body{padding:0 18px 18px}++.sc-fold-body .sc-sec{max-width:none;margin:0;padding:0}++.sc-fold-body .sc-sec+.sc-sec{margin-top:22px}++/* A list that is one sideways row on a phone (scroll snap, the next card peeking) and whatever the page made it on wide++ screens. Every item stays in the HTML. --sc-row-w sets a card's width (82% by default; 46% for small tiles). */++@media (max-width:599px){++.sc-row.sc-row{display:flex;flex-wrap:nowrap;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:16px;scrollbar-width:none;gap:10px;margin-left:-16px;margin-right:-16px;padding:0 16px 4px;grid-template-columns:none}++.sc-row.sc-row::-webkit-scrollbar{display:none}++.sc-row.sc-row>*{flex:0 0 var(--sc-row-w,82%);min-width:0;scroll-snap-align:start}++.sc-fold-hint{display:none}++}++@media (prefers-reduced-motion:reduce){.sc-fold>summary::after{transition:none}}/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}@@@ -142,6 +142,18 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;}++/**++ * Read-once material — how it works, the code samples, what every page shows — folded under its own heading. The++ * heading stays visible and keeps its id, so a link to it still lands and the page outline does not change; `hint` is a++ * short line beside it on wide screens. `html` is the caller's markup (other showcase sections nest: they lose their++ * own width and padding inside). Closed by default; `open: true` for a fold the reader should see first.++ */++function fold({ id: sid, title, hint, html, open = false } = {}) {++ if (!title || html == null || html === '') return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec sc-fold-sec"${id(sid)} aria-labelledby="${h}"><details class="sc-fold"${open ? ' open' : ''}><summary><h2 id="${h}">${esc(title)}</h2>${hint ? `<span class="sc-fold-hint">${esc(hint)}</span>` : ''}</summary><div class="sc-fold-body">${html}</div></details></section>`;++}++const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name@@@ -542,6 +554,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}++.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}++.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}++.sc-fold>summary::-webkit-details-marker{display:none}++.sc-fold>summary::after{content:'';position:absolute;right:22px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}++.sc-fold[open]>summary::after{transform:rotate(-135deg);margin-top:-2px}++.sc-fold>summary:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:-2px;border-radius:16px}++.sc-fold>summary h2{margin:0;font-size:clamp(18px,2.2vw,22px)}++.sc-fold-hint{color:var(--text-muted,#7386a3);font-size:14px;font-weight:600;text-align:right}++.sc-fold-body{padding:0 18px 18px}++.sc-fold-body .sc-sec{max-width:none;margin:0;padding:0}++.sc-fold-body .sc-sec+.sc-sec{margin-top:22px}++/* A list that is one sideways row on a phone (scroll snap, the next card peeking) and whatever the page made it on wide++ screens. Every item stays in the HTML. --sc-row-w sets a card's width (82% by default; 46% for small tiles). */++@media (max-width:599px){++.sc-row.sc-row{display:flex;flex-wrap:nowrap;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:16px;scrollbar-width:none;gap:10px;margin-left:-16px;margin-right:-16px;padding:0 16px 4px;grid-template-columns:none}++.sc-row.sc-row::-webkit-scrollbar{display:none}++.sc-row.sc-row>*{flex:0 0 var(--sc-row-w,82%);min-width:0;scroll-snap-align:start}++.sc-fold-hint{display:none}++}++@media (prefers-reduced-motion:reduce){.sc-fold>summary::after{transition:none}}/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}@@@ -601,4 +633,4 @@ html{overflow-x:clip}@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}`;--module.exports = { room, hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };++module.exports = { room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };@@@ -20,6 +20,16 @@ assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('<b>')assert.ok(f.includes('data-icon="tools"'), 'ov: icons render the shared ring icon');assert.strictEqual(f, sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] }), 'the same input renders the same bytes');++// A fold: its heading stays visible and keeps its id; nothing to fold renders nothing.++const fo = sc.fold({ id: 'how', title: 'How it <works>', hint: 'four steps', html: sc.steps({ title: 'Steps', items: [{ title: 'One', text: 't' }] }) });++assert.ok(fo.startsWith('<section class="sc-sec sc-fold-sec" id="how" aria-labelledby="sc-how-it-works">'), 'the fold is a labelled section');++assert.ok(fo.includes('<details class="sc-fold"><summary><h2 id="sc-how-it-works">How it <works></h2><span class="sc-fold-hint">four steps</span></summary>'), 'the heading is in the summary, escaped');++assert.ok(fo.includes('<div class="sc-fold-body"><section class="sc-sec"'), 'other sections nest inside');++assert.ok(sc.fold({ title: 'T', html: '<p>x</p>', open: true }).includes('<details class="sc-fold" open>'), 'open on request');++assert.strictEqual(sc.fold({ title: 'T', html: '' }), '', 'nothing to fold renders nothing');++assert.strictEqual(sc.fold({ html: '<p>x</p>' }), '', 'a fold needs a heading');++assert.ok(sc.CSS.includes('.sc-row.sc-row{display:flex;flex-wrap:nowrap'), 'the phone row does not inherit a wrap');++const c = sc.compare({ title: 'Compare', columns: ['Us', 'Them'], rows: [{ label: 'Open', values: [true, false] }, { label: 'Price', values: ['Free', 'Paid'] }] });assert.ok(c.includes('aria-label="yes"') && c.includes('aria-label="no"') && c.includes('<td class="sc-us">Free</td>'));const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'month', items: ['a'] }, { name: 'Pro', price: '$5', per: 'month', highlight: true, action: { label: 'Buy', href: '/buy' } }] });
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.44.0 — 2026-10-29
+
+- **`showcase.fold({ id, title, hint, html, open })`** folds read-once material (how it works, the code samples, what
+ every page shows) under its own heading. The heading stays visible in the summary and keeps its id, so links still
+ land and the outline does not change; other showcase sections nest inside and drop their own width and padding. It
+ takes the sections' width and font, which the eleven local folds written on 2026-10-29 had to repeat (three of them
+ missed: one rendered in the browser's serif, edge to edge).
+- **`.sc-row`** on any list makes it one sideways row on a phone (scroll snap, the next card peeking, every item still in
+ the HTML) and leaves it as the page made it on wider screens. `--sc-row-w` sets the card width (82% by default). It
+ wins over a page's own grid and never inherits a `flex-wrap` (the video home's figures wrapped into 2×2).
+
## 4.43.0 — 2026-10-29
## 4.43.0 — 2026-10-29
- **A theme for one site.** `shell.page` writes `<html data-ov-service="<site>">` (the page's service id, when it is a short
- **A theme for one site.** `shell.page` writes `<html data-ov-service="<site>">` (the page's service id, when it is a short
@@ -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.43.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.44.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.43.0",
+ "version": "4.44.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)",
@@ -494,6 +494,20 @@ that fills its rows; four tiles are four across on a phone, two by two from 600
@@ -494,6 +494,20 @@ that fills its rows; four tiles are four across on a phone, two by two from 600
Don't pass the site's own name as the eyebrow: it is left out, because the navbar's brand already says it. Use the
Don't pass the site's own name as the eyebrow: it is left out, because the navbar's brand already says it. Use the
eyebrow for something worth reading first ("Free, nothing to install"), with `{ icon, text }` for an icon.
eyebrow for something worth reading first ("Free, nothing to install"), with `{ icon, text }` for an icon.
+### Folds and phone rows (4.44.0)
+
+A product home is "a taste and a door": on a phone it shows a little of everything and links to the rest. Two pieces
+do most of that:
+
+- `showcase.fold({ title: 'How it works', hint: 'four steps', html: showcase.steps({…}) + showcase.code({…}) })` for
+ what is read once. The heading stays visible (and its id with it); the steps, facts or code open on a tap.
+- `class="sc-row"` on a card list (`<ul class="my-cards sc-row">`) for anything with more than four cards: on a phone
+ it is one sideways row with the next card peeking, so twenty listings cost one card's height and none is dropped.
+ Small tiles set `style="--sc-row-w:46%"`.
+
+Lists that are text (questions, chips, table rows) are cut on phones instead, with `nth-child` and a link to the whole
+list; a closing call that repeats the hero, or a "find your way around" grid that repeats the nav, is left out.
+
## Balances
## Balances
`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance
`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.43.0",
+ "version": "4.44.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.43.0",
+ "version": "4.44.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.43.0",
+ "version": "4.44.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": {
@@ -135,6 +135,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -135,6 +135,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
+.sc-fold>summary::-webkit-details-marker{display:none}
+.sc-fold>summary::after{content:'';position:absolute;right:22px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
+.sc-fold[open]>summary::after{transform:rotate(-135deg);margin-top:-2px}
+.sc-fold>summary:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:-2px;border-radius:16px}
+.sc-fold>summary h2{margin:0;font-size:clamp(18px,2.2vw,22px)}
+.sc-fold-hint{color:var(--text-muted,#7386a3);font-size:14px;font-weight:600;text-align:right}
+.sc-fold-body{padding:0 18px 18px}
+.sc-fold-body .sc-sec{max-width:none;margin:0;padding:0}
+.sc-fold-body .sc-sec+.sc-sec{margin-top:22px}
+/* A list that is one sideways row on a phone (scroll snap, the next card peeking) and whatever the page made it on wide
+ screens. Every item stays in the HTML. --sc-row-w sets a card's width (82% by default; 46% for small tiles). */
+@media (max-width:599px){
+.sc-row.sc-row{display:flex;flex-wrap:nowrap;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:16px;scrollbar-width:none;gap:10px;margin-left:-16px;margin-right:-16px;padding:0 16px 4px;grid-template-columns:none}
+.sc-row.sc-row::-webkit-scrollbar{display:none}
+.sc-row.sc-row>*{flex:0 0 var(--sc-row-w,82%);min-width:0;scroll-snap-align:start}
+.sc-fold-hint{display:none}
+}
+@media (prefers-reduced-motion:reduce){.sc-fold>summary::after{transition:none}}
/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */
/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */
.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}
.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}
.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}
.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}
@@ -142,6 +142,18 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {
@@ -142,6 +142,18 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {
return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;
return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;
}
}
+/**
+ * Read-once material — how it works, the code samples, what every page shows — folded under its own heading. The
+ * heading stays visible and keeps its id, so a link to it still lands and the page outline does not change; `hint` is a
+ * short line beside it on wide screens. `html` is the caller's markup (other showcase sections nest: they lose their
+ * own width and padding inside). Closed by default; `open: true` for a fold the reader should see first.
+ */
+function fold({ id: sid, title, hint, html, open = false } = {}) {
+ if (!title || html == null || html === '') return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec sc-fold-sec"${id(sid)} aria-labelledby="${h}"><details class="sc-fold"${open ? ' open' : ''}><summary><h2 id="${h}">${esc(title)}</h2>${hint ? `<span class="sc-fold-hint">${esc(hint)}</span>` : ''}</summary><div class="sc-fold-body">${html}</div></details></section>`;
+}
+
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
@@ -542,6 +554,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -542,6 +554,26 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq summary::after{content:'';position:absolute;right:18px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq details[open] summary::after{transform:rotate(-135deg);margin-top:-2px}
.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
.sc-faq p{margin:0;padding:0 16px 16px;color:var(--text-secondary,#96a7c2);line-height:1.6}
+.sc-fold{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
+.sc-fold>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:0 48px 0 18px;cursor:pointer;list-style:none;position:relative}
+.sc-fold>summary::-webkit-details-marker{display:none}
+.sc-fold>summary::after{content:'';position:absolute;right:22px;top:50%;width:8px;height:8px;margin-top:-6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;opacity:.7}
+.sc-fold[open]>summary::after{transform:rotate(-135deg);margin-top:-2px}
+.sc-fold>summary:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:-2px;border-radius:16px}
+.sc-fold>summary h2{margin:0;font-size:clamp(18px,2.2vw,22px)}
+.sc-fold-hint{color:var(--text-muted,#7386a3);font-size:14px;font-weight:600;text-align:right}
+.sc-fold-body{padding:0 18px 18px}
+.sc-fold-body .sc-sec{max-width:none;margin:0;padding:0}
+.sc-fold-body .sc-sec+.sc-sec{margin-top:22px}
+/* A list that is one sideways row on a phone (scroll snap, the next card peeking) and whatever the page made it on wide
+ screens. Every item stays in the HTML. --sc-row-w sets a card's width (82% by default; 46% for small tiles). */
+@media (max-width:599px){
+.sc-row.sc-row{display:flex;flex-wrap:nowrap;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:16px;scrollbar-width:none;gap:10px;margin-left:-16px;margin-right:-16px;padding:0 16px 4px;grid-template-columns:none}
+.sc-row.sc-row::-webkit-scrollbar{display:none}
+.sc-row.sc-row>*{flex:0 0 var(--sc-row-w,82%);min-width:0;scroll-snap-align:start}
+.sc-fold-hint{display:none}
+}
+@media (prefers-reduced-motion:reduce){.sc-fold>summary::after{transition:none}}
/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */
/* apps(): a launcher. Phones: icon tiles, name under the icon; from 600 px: rows with a one-line tagline. */
.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}
.sc-apps-group{margin:18px 0 8px;font-size:13px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}
.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}
.sc-apps-sec .sc-apps-group:first-of-type{margin-top:6px}
@@ -601,4 +633,4 @@ html{overflow-x:clip}
@@ -601,4 +633,4 @@ html{overflow-x:clip}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}
`;
`;
-module.exports = { room, hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
+module.exports = { room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
@@ -20,6 +20,16 @@ assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('<b>')
@@ -20,6 +20,16 @@ assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('<b>')
assert.ok(f.includes('data-icon="tools"'), 'ov: icons render the shared ring icon');
assert.ok(f.includes('data-icon="tools"'), 'ov: icons render the shared ring icon');
assert.strictEqual(f, sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] }), 'the same input renders the same bytes');
assert.strictEqual(f, sc.features({ title: 'What you get', items: [{ icon: 'fa-key', title: '<b>', text: 't', href: '/k' }, { icon: 'ov:tools', title: 'Tools' }] }), 'the same input renders the same bytes');
+// A fold: its heading stays visible and keeps its id; nothing to fold renders nothing.
+const fo = sc.fold({ id: 'how', title: 'How it <works>', hint: 'four steps', html: sc.steps({ title: 'Steps', items: [{ title: 'One', text: 't' }] }) });
+assert.ok(fo.startsWith('<section class="sc-sec sc-fold-sec" id="how" aria-labelledby="sc-how-it-works">'), 'the fold is a labelled section');
+assert.ok(fo.includes('<details class="sc-fold"><summary><h2 id="sc-how-it-works">How it <works></h2><span class="sc-fold-hint">four steps</span></summary>'), 'the heading is in the summary, escaped');
+assert.ok(fo.includes('<div class="sc-fold-body"><section class="sc-sec"'), 'other sections nest inside');
+assert.ok(sc.fold({ title: 'T', html: '<p>x</p>', open: true }).includes('<details class="sc-fold" open>'), 'open on request');
+assert.strictEqual(sc.fold({ title: 'T', html: '' }), '', 'nothing to fold renders nothing');
+assert.strictEqual(sc.fold({ html: '<p>x</p>' }), '', 'a fold needs a heading');
+assert.ok(sc.CSS.includes('.sc-row.sc-row{display:flex;flex-wrap:nowrap'), 'the phone row does not inherit a wrap');
+
const c = sc.compare({ title: 'Compare', columns: ['Us', 'Them'], rows: [{ label: 'Open', values: [true, false] }, { label: 'Price', values: ['Free', 'Paid'] }] });
const c = sc.compare({ title: 'Compare', columns: ['Us', 'Them'], rows: [{ label: 'Open', values: [true, false] }, { label: 'Price', values: ['Free', 'Paid'] }] });
assert.ok(c.includes('aria-label="yes"') && c.includes('aria-label="no"') && c.includes('<td class="sc-us">Free</td>'));
assert.ok(c.includes('aria-label="yes"') && c.includes('aria-label="no"') && c.includes('<td class="sc-us">Free</td>'));
const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'month', items: ['a'] }, { name: 'Pro', price: '$5', per: 'month', highlight: true, action: { label: 'Buy', href: '/buy' } }] });
const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'month', items: ['a'] }, { name: 'Pro', price: '$5', per: 'month', highlight: true, action: { label: 'Buy', href: '/buy' } }] });