openvibers/openvibe.shared Commits

4.38.0: showcase.apps, a launcher for a set of sites or apps (#176)

OP OpenVibers committed 16 days ago · 221d53a943 · 9 files, +94 −8

A phone's home screen for the network: icon tiles three or four to a row with the name under the icon on a phone,
rows with a one-line tagline from 600 px, optional groups (neighbourhoods) and a See all link. The network's front
door listed its 47 sites as feature cards, 8,459 px on a phone; this is the piece it was missing. Escaped, unsafe
links dropped, empty groups left out; CSS in showcase.js (built into showcase.css).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9494282..a17a4b6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,13 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.38.0 — 2026-10-29
++
++- **`showcase.apps`: a launcher.** A set of apps or sites as a phone's home screen shows them: icon tiles three or four
++ to a row with the name under the icon on a phone, rows with a one-line tagline from 600 px, optional groups and a
++ See all link. The network's front door listed its 47 sites as feature cards, 8,459 px on a phone; as a launcher it is
++ about a fifth of that.
++
## 4.37.0 — 2026-10-29
- **Kit 2 has its own files: `kit-data.css` and `kit-data-web.js`.** Its look and behaviour moved out of `kit.css` and
ddiff --git a/README.md b/README.md
iindex 9216250..f1d20e5 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.37.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.38.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 fd2b305..3e569b3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.37.0",
++ "version": "4.38.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 2779e60..cdc1599 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -473,6 +473,12 @@ still render as their own rows.
## Home page heroes (showcase)
++**`sc.apps({ title, lede, groups: [{ name, items: [{ icon, name, text, href }] }], more })`** (4.38.0) shows a set of
++apps as a launcher, the way a phone's home screen does: icon tiles three or four to a row with the name under the icon
++on a phone, rows with a one-line tagline from 600 px. Use it where a page lists sites or products to jump to (the
++network's front door, /sites, a directory); it replaces a stack of feature cards, which on a phone is one screen per
++four sites.
++
`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since
4.23.0):
- it starts right under the bar;
ddiff --git a/package-lock.json b/package-lock.json
iindex dd167e0..e7a01ca 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.37.0",
++ "version": "4.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.37.0",
++ "version": "4.38.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 01af414..da3d1ef 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.37.0",
++ "version": "4.38.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 776fec3..b24cde4 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -135,6 +135,25 @@ 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}
++/* 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}
++.sc-apps{list-style:none;margin:0;padding:0;display:grid;gap:6px;grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}
++.sc-apps a{display:flex;flex-direction:column;align-items:center;gap:6px;min-height:44px;padding:10px 4px;border-radius:14px;color:inherit;text-decoration:none;text-align:center;transition:background .15s}
++.sc-apps a:hover,.sc-apps a:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);outline:0}
++.sc-apps a:focus-visible{box-shadow:0 0 0 2px var(--accent,#3b82f6)}
++.sc-app-t{min-width:0;max-width:100%}
++.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
++.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++.sc-apps-more{margin:14px 0 0;font-weight:700}
++.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}
++@media (min-width:600px){
++.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
++.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
++.sc-apps b{font-size:15px}
++.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
++}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@@ -167,7 +186,7 @@ html{overflow-x:clip}
.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
}
--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net 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}}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
ddiff --git a/showcase.js b/showcase.js
iindex 1e698bc..7adb585 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -327,6 +327,23 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc
return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul></section>`;
}
++/**
++ * apps({ id, title, lede, groups: [{ name, items }], items, more: { href, label } }) — a set of apps as a launcher, the
++ * way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon
++ * tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider
++ * screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each
++ * set (the network's neighbourhoods); items without groups is one grid. Empty → ''.
++ */
++function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {
++ const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
++ if (!sets.length) return '';
++ const h = `sc-${slug(title || 'apps')}`;
++ const tile = (x) => `<li><a href="${esc(safeHref(x.href))}"><span class="sc-ic sc-ic-ov">${icon(x.icon || 'ov:spark', 40)}</span><span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
++ const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps">${g.items.map(tile).join('')}</ul>`).join('');
++ const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';
++ return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;
++}
++
/** The closing call to action. */
function cta({ title, text, actions: acts } = {}) {
if (!title) return '';
@@@ -522,6 +539,25 @@ 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}
++/* 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}
++.sc-apps{list-style:none;margin:0;padding:0;display:grid;gap:6px;grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}
++.sc-apps a{display:flex;flex-direction:column;align-items:center;gap:6px;min-height:44px;padding:10px 4px;border-radius:14px;color:inherit;text-decoration:none;text-align:center;transition:background .15s}
++.sc-apps a:hover,.sc-apps a:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent);outline:0}
++.sc-apps a:focus-visible{box-shadow:0 0 0 2px var(--accent,#3b82f6)}
++.sc-app-t{min-width:0;max-width:100%}
++.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
++.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++.sc-apps-more{margin:14px 0 0;font-weight:700}
++.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}
++@media (min-width:600px){
++.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
++.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
++.sc-apps b{font-size:15px}
++.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
++}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@@ -554,7 +590,7 @@ html{overflow-x:clip}
.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
}
--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net 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, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
++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 };
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 07cb62d..74f2927 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -152,4 +152,22 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(/<p class="sc-eyebrow">Free for OpenVibe\.Live streamers<\/p>/.test(sc.hero({ eyebrow: 'Free for OpenVibe.Live streamers', title: 'T' })), 'a name later in the phrase stays');
}
++// ── apps(): the launcher ──
++{
++ const sc = require('../showcase');
++ const h = sc.apps({ id: 'network', title: 'Every site', lede: 'One account.', more: { href: '/sites', label: 'All 48 sites' }, groups: [
++ { name: 'Watch <and> stream', items: [{ icon: 'ov:live', name: 'Live <x>', text: 'Go live from your browser', href: 'https://openvibe.live/' }, { name: 'Bad', href: 'javascript:alert(1)' }] },
++ { name: 'Empty', items: [] },
++ ] });
++ assert.ok(h.startsWith('<section class="sc-sec sc-apps-sec" id="network" aria-labelledby="sc-every-site">'), 'a labelled section');
++ assert.ok(h.includes('<h3 class="sc-apps-group">Watch &lt;and&gt; stream</h3>') && !h.includes('>Empty<'), 'groups are escaped; an empty one is left out');
++ assert.ok(h.includes('<b>Live &lt;x&gt;</b><small>Go live from your browser</small>') && h.includes('href="https://openvibe.live/"'), 'a tile: icon, name, text');
++ assert.ok(!h.includes('javascript:') && !h.includes('>Bad<'), 'a tile with an unsafe link is dropped');
++ assert.ok(h.includes('<p class="sc-apps-more"><a href="/sites">All 48 sites →</a></p>'), 'See all');
++ assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');
++ assert.ok(sc.apps({ items: [{ name: 'A', href: '/a' }] }).includes('<ul class="sc-apps">'), 'items without groups is one grid');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'showcase.css'), 'utf8');
++ assert.ok(css.includes('.sc-apps{') && css.includes('grid-template-columns:repeat(auto-fill,minmax(88px,1fr))'), 'phones: icon tiles');
++}
++
console.log('showcase: all checks passed');