openvibers/openvibe.shared Commits
Footer: the link columns fold on phones (4.53.0) (#199)
Changes
@@@ -6,6 +6,19 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.53.0 — 2026-10-30++++- **The full footer folds on phones** (the short-pages rule: a phone page is one or two screens, and the footer alone++ was 1.1–1.4 of them). Each link column (the site's own, Network, Popular tools, Legal) is a `<details>` fold.++ - **On a phone** (560 px and under) each column is a 48 px row with its heading and a chevron, folded. The footer++ measures 506 px at 390 px wide instead of 891 px.++ - **A desktop is unchanged.** The heading is only a heading: not a control and not in the tab order.++ - **A fold the person opens stays open** through a re-render. Turning a phone sideways (wider than 560 px) opens++ every column again.++ - **Every link stays in the page.** The server writes every fold open, so crawlers and pages without script see++ them all; the client folds them on a phone.++- `test/footer-folds.test.js`: the folds' markup, the desktop and phone rules, and the compact footer's whole footer.++## 4.52.0 — 2026-10-30- **The Dock's Chats tab** (Dock plan K-D2). The person's conversations and DMs, on every OpenVibe site.@@@ -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.52.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.53.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.52.0",++ "version": "4.53.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)",@@@ -279,12 +279,12 @@/** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) {---- const cols = (c.links || []).map(group => `-- <nav class="ovf-col" aria-label="${esc(group.heading)}">-- <h3>${esc(group.heading)}</h3>-- ${group.items.map(linkTag).join('')}-- </nav>`).join('');++ // Each link column is a fold: open on a desktop (its heading reads and behaves as a heading), folded on a phone++ // (a 48 px row with a chevron), so a phone's footer is about one screen shorter. Every link stays in the page.++ const fold = (label, heading, body) => `++ <nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3></summary>++ <div class="ovf-fold-body">${body}</div></details></nav>`;++ const cols = (c.links || []).map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''))).join('');return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}@@@ -298,21 +298,12 @@</div></div>${cols}-- ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">-- <h3>Network</h3>-- ${network.slice(0, 8).map(linkTag).join('')}-- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>-- </nav>` : ''}-- ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">-- <h3>Popular tools</h3>-- ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}-- <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>-- </nav>` : ''}-- <nav class="ovf-col" aria-label="Legal">-- <h3>Legal</h3>-- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}-- ${reportLink()}-- </nav>++ ${c.showNetwork ? fold('The OpenVibe network', 'Network', `${network.slice(0, 8).map(linkTag).join('')}++ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`) : ''}++ ${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`) : ''}++ ${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}++ ${reportLink()}`)}</div>${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div><div class="ovf-bar">@@@ -382,6 +373,9 @@.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}++.ovf-fold>summary{list-style:none;cursor:default;pointer-events:none}++.ovf-fold>summary::-webkit-details-marker{display:none}++.ovf-fold-body{display:flex;flex-direction:column;gap:8px;margin-top:8px}.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}@media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:32px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:0;right:0;top:-12px;bottom:-12px}}@@@ -446,7 +440,15 @@@media (max-width:560px){.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}-- .ovf-cols{gap:22px 16px}++ .ovf-cols{gap:0 16px}++ .ovf-col--brand{margin-bottom:14px}++ .ovf-col:not(.ovf-col--brand){grid-column:1/-1;border-top:1px solid var(--border,rgba(255,255,255,.08))}++ .ovf-col:not(.ovf-col--brand):last-child{border-bottom:1px solid var(--border,rgba(255,255,255,.08))}++ .ovf-fold>summary{pointer-events:auto;cursor:pointer;display:flex;align-items:center;justify-content:space-between;min-height:48px}++ .ovf-fold>summary h3{margin:0}++ .ovf-fold>summary::after{content:"";width:.5em;height:.5em;margin-right:4px;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:translateY(-.2em) rotate(45deg);transition:transform .2s}++ .ovf-fold[open]>summary::after{transform:translateY(.1em) rotate(-135deg)}++ .ovf-fold-body{margin-top:0;padding-bottom:14px}.ovf-bar{justify-content:center;text-align:center;padding-inline:0}.ovf-legal-inline{justify-content:center}.ovf-account{flex-direction:column;align-items:flex-start}@@@ -465,6 +467,39 @@document.head.appendChild(st);}++ // ── Phones: the columns fold ───────────────────────────────++ // The server and the first render write every column open (no script: everything shows). On a phone the client++ // folds them, except the ones the person opened themselves, which stay open through any later render.++ // Wider than a phone (a phone turned sideways too) every column opens again and its heading is only a heading.++ const _openFolds = new Set();++ let _phoneQuery = null;++ function foldOnPhones(el) {++ if (!el || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;++ if (!_phoneQuery) {++ _phoneQuery = window.matchMedia('(max-width: 560px)');++ const again = () => foldOnPhones(_mounted);++ if (_phoneQuery.addEventListener) _phoneQuery.addEventListener('change', again); else if (_phoneQuery.addListener) _phoneQuery.addListener(again);++ }++ const phone = _phoneQuery.matches;++ if (!el._ovFolds) {++ el._ovFolds = true;++ // The person's own taps on a heading (toggle events are async, so the intent is read from the click).++ el.addEventListener('click', (e) => {++ const sum = e.target && e.target.closest && e.target.closest('details.ovf-fold > summary');++ if (!sum) return;++ if (!_phoneQuery.matches) { e.preventDefault(); return; }++ const d = sum.parentNode, nav = d.closest('nav'), key = nav ? nav.getAttribute('aria-label') : '';++ if (d.open) _openFolds.delete(key); else _openFolds.add(key);++ });++ }++ el.querySelectorAll('details.ovf-fold').forEach((d) => {++ const nav = d.closest('nav');++ d.open = !phone || _openFolds.has(nav && nav.getAttribute('aria-label'));++ const sum = d.querySelector('summary');++ if (sum) sum.tabIndex = phone ? 0 : -1;++ });++ }++// ── Mount ──────────────────────────────────────────────────function render() {if (!_mounted) return;@@@ -474,6 +509,7 @@_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');_mounted.setAttribute('data-variant', variant);_mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open });++ foldOnPhones(_mounted);if (!_mounted._ovReport) {_mounted._ovReport = true;const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.52.0",++ "version": "4.53.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// The full footer's link columns are folds (footer.js; the owner's short-pages rule, 2026-10-28: a phone page is one or++// two screens, and the footer alone was 1.1–1.4 of them): the server writes every column open, so a page without++// script and every crawler still has all the links; on a desktop the heading reads and behaves as a heading; on a++// phone each column is a 48 px row with a chevron.++const assert = require('assert');++const footer = require('../footer');++++const links = [++ { heading: 'Watch', items: [{ name: 'Live now', url: '/browse' }, { name: 'Clips', url: '/clips' }] },++ { heading: 'Create', items: [{ name: 'Go live', url: '/go-live' }] },++];++const html = footer.buildHTML({ service: 'live', variant: 'full', links });++++const folds = html.match(/<details class="ovf-fold"[^>]*>/g) || [];++assert.ok(folds.length >= 4, `the site's columns, Network and Legal are folds (${folds.length})`);++assert.ok(folds.every((d) => / open\b/.test(d)), 'every fold is written open');++for (const [label, heading] of [['Watch', 'Watch'], ['Create', 'Create'], ['The OpenVibe network', 'Network'], ['Legal', 'Legal']]) {++ const re = new RegExp(`<nav class="ovf-col" aria-label="${label}"><details class="ovf-fold" open><summary><h3>${heading}</h3></summary>`);++ assert.match(html, re, `${label}: a nav, its fold and its heading as the summary`);++}++for (const href of ['/browse', '/clips', '/go-live']) assert.ok(html.includes(`href="${href}"`), `${href} stays in the page`);++++// The compact footer opens into the whole one: its columns are folds too.++const compact = footer.buildHTML({ service: 'live', variant: 'compact', links });++assert.ok((compact.match(/<details class="ovf-fold" open>/g) || []).length >= 4, 'the compact footer\'s whole footer folds the same way');++++// Desktop: the summary is not a control. Phone: a 48 px row with a chevron.++const css = footer.CSS;++assert.match(css, /\.ovf-fold>summary\{list-style:none;cursor:default;pointer-events:none\}/);++const phone = css.slice(css.indexOf('@media (max-width:560px)'));++assert.ok(phone.length < css.length, 'the phone block exists');++assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);++assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);++++console.log('footer-folds: all checks passed');
@@ -6,6 +6,19 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,19 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.53.0 — 2026-10-30
+
+- **The full footer folds on phones** (the short-pages rule: a phone page is one or two screens, and the footer alone
+ was 1.1–1.4 of them). Each link column (the site's own, Network, Popular tools, Legal) is a `<details>` fold.
+ - **On a phone** (560 px and under) each column is a 48 px row with its heading and a chevron, folded. The footer
+ measures 506 px at 390 px wide instead of 891 px.
+ - **A desktop is unchanged.** The heading is only a heading: not a control and not in the tab order.
+ - **A fold the person opens stays open** through a re-render. Turning a phone sideways (wider than 560 px) opens
+ every column again.
+ - **Every link stays in the page.** The server writes every fold open, so crawlers and pages without script see
+ them all; the client folds them on a phone.
+- `test/footer-folds.test.js`: the folds' markup, the desktop and phone rules, and the compact footer's whole footer.
+
## 4.52.0 — 2026-10-30
## 4.52.0 — 2026-10-30
- **The Dock's Chats tab** (Dock plan K-D2). The person's conversations and DMs, on every OpenVibe site.
- **The Dock's Chats tab** (Dock plan K-D2). The person's conversations and DMs, on every OpenVibe site.
@@ -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.52.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.53.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.52.0",
+ "version": "4.53.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)",
@@ -279,12 +279,12 @@
@@ -279,12 +279,12 @@
/** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */
/** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */
function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) {
function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) {
-
- const cols = (c.links || []).map(group => `
- <nav class="ovf-col" aria-label="${esc(group.heading)}">
- <h3>${esc(group.heading)}</h3>
- ${group.items.map(linkTag).join('')}
- </nav>`).join('');
+ // Each link column is a fold: open on a desktop (its heading reads and behaves as a heading), folded on a phone
+ // (a 48 px row with a chevron), so a phone's footer is about one screen shorter. Every link stays in the page.
+ const fold = (label, heading, body) => `
+ <nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3></summary>
+ <div class="ovf-fold-body">${body}</div></details></nav>`;
+ const cols = (c.links || []).map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''))).join('');
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}
${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}
@@ -298,21 +298,12 @@
@@ -298,21 +298,12 @@
</div>
</div>
</div>
</div>
${cols}
${cols}
- ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
- <h3>Network</h3>
- ${network.slice(0, 8).map(linkTag).join('')}
- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>
- </nav>` : ''}
- ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
- <h3>Popular tools</h3>
- ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
- <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>
- </nav>` : ''}
- <nav class="ovf-col" aria-label="Legal">
- <h3>Legal</h3>
- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
- ${reportLink()}
- </nav>
+ ${c.showNetwork ? fold('The OpenVibe network', 'Network', `${network.slice(0, 8).map(linkTag).join('')}
+ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`) : ''}
+ ${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
+ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`) : ''}
+ ${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
+ ${reportLink()}`)}
</div>
</div>
${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div>
${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div>
<div class="ovf-bar">
<div class="ovf-bar">
@@ -382,6 +373,9 @@
@@ -382,6 +373,9 @@
.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
+.ovf-fold>summary{list-style:none;cursor:default;pointer-events:none}
+.ovf-fold>summary::-webkit-details-marker{display:none}
+.ovf-fold-body{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}
.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}
@media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:32px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:0;right:0;top:-12px;bottom:-12px}}
@media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:32px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:0;right:0;top:-12px;bottom:-12px}}
@@ -446,7 +440,15 @@
@@ -446,7 +440,15 @@
@media (max-width:560px){
@media (max-width:560px){
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
- .ovf-cols{gap:22px 16px}
+ .ovf-cols{gap:0 16px}
+ .ovf-col--brand{margin-bottom:14px}
+ .ovf-col:not(.ovf-col--brand){grid-column:1/-1;border-top:1px solid var(--border,rgba(255,255,255,.08))}
+ .ovf-col:not(.ovf-col--brand):last-child{border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
+ .ovf-fold>summary{pointer-events:auto;cursor:pointer;display:flex;align-items:center;justify-content:space-between;min-height:48px}
+ .ovf-fold>summary h3{margin:0}
+ .ovf-fold>summary::after{content:"";width:.5em;height:.5em;margin-right:4px;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:translateY(-.2em) rotate(45deg);transition:transform .2s}
+ .ovf-fold[open]>summary::after{transform:translateY(.1em) rotate(-135deg)}
+ .ovf-fold-body{margin-top:0;padding-bottom:14px}
.ovf-bar{justify-content:center;text-align:center;padding-inline:0}
.ovf-bar{justify-content:center;text-align:center;padding-inline:0}
.ovf-legal-inline{justify-content:center}
.ovf-legal-inline{justify-content:center}
.ovf-account{flex-direction:column;align-items:flex-start}
.ovf-account{flex-direction:column;align-items:flex-start}
@@ -465,6 +467,39 @@
@@ -465,6 +467,39 @@
document.head.appendChild(st);
document.head.appendChild(st);
}
}
+ // ── Phones: the columns fold ───────────────────────────────
+ // The server and the first render write every column open (no script: everything shows). On a phone the client
+ // folds them, except the ones the person opened themselves, which stay open through any later render.
+ // Wider than a phone (a phone turned sideways too) every column opens again and its heading is only a heading.
+ const _openFolds = new Set();
+ let _phoneQuery = null;
+ function foldOnPhones(el) {
+ if (!el || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
+ if (!_phoneQuery) {
+ _phoneQuery = window.matchMedia('(max-width: 560px)');
+ const again = () => foldOnPhones(_mounted);
+ if (_phoneQuery.addEventListener) _phoneQuery.addEventListener('change', again); else if (_phoneQuery.addListener) _phoneQuery.addListener(again);
+ }
+ const phone = _phoneQuery.matches;
+ if (!el._ovFolds) {
+ el._ovFolds = true;
+ // The person's own taps on a heading (toggle events are async, so the intent is read from the click).
+ el.addEventListener('click', (e) => {
+ const sum = e.target && e.target.closest && e.target.closest('details.ovf-fold > summary');
+ if (!sum) return;
+ if (!_phoneQuery.matches) { e.preventDefault(); return; }
+ const d = sum.parentNode, nav = d.closest('nav'), key = nav ? nav.getAttribute('aria-label') : '';
+ if (d.open) _openFolds.delete(key); else _openFolds.add(key);
+ });
+ }
+ el.querySelectorAll('details.ovf-fold').forEach((d) => {
+ const nav = d.closest('nav');
+ d.open = !phone || _openFolds.has(nav && nav.getAttribute('aria-label'));
+ const sum = d.querySelector('summary');
+ if (sum) sum.tabIndex = phone ? 0 : -1;
+ });
+ }
+
// ── Mount ──────────────────────────────────────────────────
// ── Mount ──────────────────────────────────────────────────
function render() {
function render() {
if (!_mounted) return;
if (!_mounted) return;
@@ -474,6 +509,7 @@
@@ -474,6 +509,7 @@
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
_mounted.setAttribute('data-variant', variant);
_mounted.setAttribute('data-variant', variant);
_mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open });
_mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open });
+ foldOnPhones(_mounted);
if (!_mounted._ovReport) {
if (!_mounted._ovReport) {
_mounted._ovReport = true;
_mounted._ovReport = true;
const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };
const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.52.0",
+ "version": "4.53.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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// The full footer's link columns are folds (footer.js; the owner's short-pages rule, 2026-10-28: a phone page is one or
+// two screens, and the footer alone was 1.1–1.4 of them): the server writes every column open, so a page without
+// script and every crawler still has all the links; on a desktop the heading reads and behaves as a heading; on a
+// phone each column is a 48 px row with a chevron.
+const assert = require('assert');
+const footer = require('../footer');
+
+const links = [
+ { heading: 'Watch', items: [{ name: 'Live now', url: '/browse' }, { name: 'Clips', url: '/clips' }] },
+ { heading: 'Create', items: [{ name: 'Go live', url: '/go-live' }] },
+];
+const html = footer.buildHTML({ service: 'live', variant: 'full', links });
+
+const folds = html.match(/<details class="ovf-fold"[^>]*>/g) || [];
+assert.ok(folds.length >= 4, `the site's columns, Network and Legal are folds (${folds.length})`);
+assert.ok(folds.every((d) => / open\b/.test(d)), 'every fold is written open');
+for (const [label, heading] of [['Watch', 'Watch'], ['Create', 'Create'], ['The OpenVibe network', 'Network'], ['Legal', 'Legal']]) {
+ const re = new RegExp(`<nav class="ovf-col" aria-label="${label}"><details class="ovf-fold" open><summary><h3>${heading}</h3></summary>`);
+ assert.match(html, re, `${label}: a nav, its fold and its heading as the summary`);
+}
+for (const href of ['/browse', '/clips', '/go-live']) assert.ok(html.includes(`href="${href}"`), `${href} stays in the page`);
+
+// The compact footer opens into the whole one: its columns are folds too.
+const compact = footer.buildHTML({ service: 'live', variant: 'compact', links });
+assert.ok((compact.match(/<details class="ovf-fold" open>/g) || []).length >= 4, 'the compact footer\'s whole footer folds the same way');
+
+// Desktop: the summary is not a control. Phone: a 48 px row with a chevron.
+const css = footer.CSS;
+assert.match(css, /\.ovf-fold>summary\{list-style:none;cursor:default;pointer-events:none\}/);
+const phone = css.slice(css.indexOf('@media (max-width:560px)'));
+assert.ok(phone.length < css.length, 'the phone block exists');
+assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);
+assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);
+
+console.log('footer-folds: all checks passed');