openvibers/openvibe.shared Commits

4.44.0: showcase.fold and the .sc-row phone row (#184)

OP OpenVibers committed 14 days ago · 714bebc8e7 · 9 files, +93 −6

showcase.fold folds read-once material under its own visible heading (its id
kept, other sections nested inside, the sections' width and font); .sc-row makes
any list one sideways snap row on a phone without inheriting a wrap. The eleven
product homes shortened on 2026-10-29 each carried their own copies; three of
those folds took the wrong font or width.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e976d77..0a2a626 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 49faf28..43e7b7f 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 860c49d..344daf1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 83b3d50..ae9401f 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -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
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
`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance
ddiff --git a/package-lock.json b/package-lock.json
iindex b9d96fb..0fe8bfa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 83f62b3..1dc7b9a 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/showcase.css b/showcase.css
iindex 0d4d0a7..84ff081 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/showcase.js b/showcase.js
iindex d02a9a2..66c9b6c 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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 };
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex bfdf05b..acd1772 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -20,6 +20,16 @@ assert.ok(f.includes('<a class="sc-card" href="/k">') && f.includes('&lt;b&gt;')
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 &lt;works&gt;</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' } }] });