openvibers/openvibe.shared Commits

Shared 2.3.1: /shared/showcase.css, the showcase sections and the ring icons' CSS as one cacheable stylesheet (files.js STYLES, served as text/css; generated by scripts/build-styles.js and checked by the showcase test), so a product home links it instead of inlining it. 47/47.

OP OpenVibers committed 29 Sept 2026 · c2238d0f51 · 8 files, +143 −7


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0793495..3535a30 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 2.3.1 — 2026-09-29
++
++- `showcase.css` is served at `/shared/showcase.css` (files.js `STYLES`, text/css): the showcase sections and the ring
++ icons' CSS as one cacheable stylesheet, so a product home links it instead of inlining ~10 KB of CSS in every page
++ (and boost loads it styles-first on a swap). Generated by `scripts/build-styles.js`; the test fails when it is stale.
++ `serve.js` answers each file with its own content type.
++
## 2.3.0 — 2026-09-29
- `openvibe-shared/showcase` (plan T11, D92): the sections a product's home page is built from — `hero`, `features`,
ddiff --git a/files.js b/files.js
iindex 9364440..c6920d3 100644
---- a/files.js
++++ b/files.js
@@@ -38,12 +38,19 @@ const BROWSER = Object.freeze([
'openvibe-sw.js', // Web Push service worker
]);
++// Stylesheets sites serve at /shared/<file> beside the scripts (generated from their module, never edited by hand).
++const STYLES = Object.freeze([
++ 'showcase.css', // openvibe-shared/showcase's sections + the ring icons (scripts/build-styles.js)
++]);
++const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
++
module.exports = {
dir: __dirname,
BROWSER,
-- isBrowserFile: (name) => BROWSER.includes(String(name)),
++ STYLES,
++ isBrowserFile: served,
path: (name) => {
-- if (!BROWSER.includes(String(name))) throw new Error(`openvibe-shared: ${name} is not a browser file`);
++ if (!served(name)) throw new Error(`openvibe-shared: ${name} is not a browser file`);
return path.join(__dirname, name);
},
};
ddiff --git a/package.json b/package.json
iindex d72c980..78a70b0 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.3.0",
++ "version": "2.3.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -70,6 +70,7 @@
},
"files": [
"*.js",
++ "*.css",
"analytics/",
"docs/schemas/",
"scripts/",
@@@ -79,7 +80,8 @@
"test": "node test/run.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
-- "build:themes": "node scripts/build-theme-loader.js"
++ "build:themes": "node scripts/build-theme-loader.js",
++ "build:styles": "node scripts/build-styles.js"
},
"repository": {
"type": "git",
ddiff --git a/scripts/build-styles.js b/scripts/build-styles.js
nnew file mode 100644
iindex 0000000..e19ab30
---- /dev/null
++++ b/scripts/build-styles.js
@@@ -0,0 +1,20 @@
++#!/usr/bin/env node
++'use strict';
++// Writes the served stylesheets from their modules (files.js STYLES): showcase.css = showcase.CSS + the ring icons'
++// CSS. `--check` exits 1 when a file differs from what its module would write (test/showcase.test.js runs it).
++const fs = require('fs');
++const path = require('path');
++const ROOT = path.join(__dirname, '..');
++const OUT = {
++ 'showcase.css': () => `/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */\n${require('../showcase').CSS.trim()}\n${require('../ov-icons').CSS.trim()}\n`,
++};
++const check = process.argv.includes('--check');
++let stale = 0;
++for (const [file, make] of Object.entries(OUT)) {
++ const want = make();
++ const p = path.join(ROOT, file);
++ const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
++ if (have === want) continue;
++ if (check) { console.error(`${file} is stale: run node scripts/build-styles.js`); stale++; } else fs.writeFileSync(p, want);
++}
++process.exit(stale ? 1 : 0);
ddiff --git a/serve.js b/serve.js
iindex 87e2b0c..ad61db9 100644
---- a/serve.js
++++ b/serve.js
@@@ -38,7 +38,7 @@ function handler() {
if (!files.isBrowserFile(name)) return next();
const hash = hashOf(name);
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
-- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
++ res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8');
// The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,
// because a stale shared script is the one thing a site's navbar must never run. Kept as it was.
res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
ddiff --git a/showcase.css b/showcase.css
nnew file mode 100644
iindex 0000000..e927866
---- /dev/null
++++ b/showcase.css
@@@ -0,0 +1,94 @@
++/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */
++.sc-hero,.sc-sec,.sc-cta{max-width:1080px;margin:0 auto;padding:0 24px}
++.sc-hero{padding-top:56px;padding-bottom:8px}
++.sc-hero.sc-split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:32px;align-items:center}
++.sc-eyebrow{display:inline-block;margin:0 0 14px;padding:4px 12px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-secondary,#101828);color:var(--accent-light,var(--accent,#60a5fa));font-size:12px;font-weight:700;letter-spacing:.4px}
++.sc-hero h1{font-size:clamp(32px,4.6vw,52px);line-height:1.08;letter-spacing:-1.2px;margin:0;font-weight:800}
++.sc-accent{background:linear-gradient(120deg,var(--accent-light,#60a5fa),var(--accent,#3b82f6),var(--secondary,#22d3ee));-webkit-background-clip:text;background-clip:text;color:transparent}
++.sc-hero-lede{margin:16px 0 0;max-width:640px;font-size:17px;line-height:1.6;color:var(--text-secondary,#96a7c2)}
++.sc-hero-aside{min-width:0}
++.sc-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
++.sc-btn{display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border-radius:12px;font-weight:700;font-size:15px;text-decoration:none;color:var(--text-primary,#e6edf7);border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;transition:border-color .15s,background .15s,transform .15s}
++.sc-btn:hover,.sc-btn:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-hover,#1c2a44);outline:0}
++.sc-btn.sc-primary{border-color:transparent;color:var(--on-accent,#fff);background:linear-gradient(135deg,var(--accent,#3b82f6),var(--accent-dark,#1d4ed8))}
++.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
++.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
++.sc-note a{color:var(--accent-light,#60a5fa)}
++.sc-sec{margin-top:56px}
++.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
++.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-card{display:flex;gap:14px;align-items:flex-start;padding:16px;border-radius:16px;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}
++a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
++.sc-ic{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;font-size:17px;color:var(--accent-light,#60a5fa);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}
++.sc-card b{display:block;font-size:15.5px}.sc-card small{display:block;margin-top:3px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
++.sc-steps{list-style:none;margin:0;padding:0;counter-reset:sc;display:grid;gap:14px 24px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
++.sc-steps li{counter-increment:sc;display:grid;grid-template-columns:34px 1fr;column-gap:12px;align-items:baseline}
++.sc-steps li::before{content:counter(sc);grid-row:span 2;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:13px;color:var(--accent-light,#60a5fa);border:1px solid var(--border-light,#2c3d5c);background:var(--bg-secondary,#101828)}
++.sc-steps b{font-size:15.5px}.sc-steps b a{color:inherit;text-decoration:underline;text-decoration-color:var(--accent,#3b82f6);text-underline-offset:3px}
++.sc-steps span{color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.5}
++.sc-code{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
++.sc-code figure{margin:0;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-primary,#0a0f1c);overflow:hidden;min-width:0}
++.sc-code figcaption{padding:8px 14px;font-size:12px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
++.sc-code pre{margin:0;padding:14px;overflow:auto;font-size:13px;line-height:1.55}
++.sc-demo{border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));overflow:hidden;background:var(--bg-secondary,#101828)}
++.sc-demo iframe{display:block;width:100%;border:0;background:var(--bg-primary,#0a0f1c)}
++.sc-table-wrap{overflow-x:auto;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08))}
++.sc-table{width:100%;border-collapse:collapse;font-size:14px}
++.sc-table th,.sc-table td{padding:11px 14px;text-align:left;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
++.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
++.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
++.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
++.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
++.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
++.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
++.sc-tier-name{font-size:13px;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted,#7386a3)}
++.sc-price{margin:6px 0 10px;font-size:28px;font-weight:800}.sc-price small{font-size:14px;font-weight:600;color:var(--text-secondary,#96a7c2)}
++.sc-tier ul{margin:0;padding-left:18px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.6}
++.sc-tier-act{margin-top:auto;padding-top:14px}
++.sc-stories{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
++.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
++.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
++.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
++.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
++.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
++@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
++@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
++@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition: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}
++.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
++.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-box:view-box;transform-origin:12px 12px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
++.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:transform .25s cubic-bezier(.2,1.4,.3,1),fill .2s}
++.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
++.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}
++.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}
++.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
++.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
++.ov-icon[data-fx=none] .ovi-comet{display:none}
++.ov-icon[data-fx=pulse] .ovi-comet{display:none}
++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
++.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
++.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
++.ov-icon[data-progress] .ovi-prog{opacity:1}
++.ov-icon[data-progress] .ovi-comet{opacity:0}
++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
++.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
++.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
++.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
++.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}
++.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
++.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
++.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
++@keyframes oviSpin{to{transform:rotate(360deg)}}
++@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
++@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
++@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
++@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
++@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}
++@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}
ddiff --git a/showcase.js b/showcase.js
iindex 3cfab88..ae7e603 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -5,7 +5,8 @@
* tokens, so every product's home reads as one family:
*
* const sc = require('openvibe-shared/showcase');
-- * `<style>${sc.CSS}</style>` + sc.hero({ eyebrow, title, accent, lede, actions, note, aside })
++ * `<link rel="stylesheet" href="${serve.url('showcase.css')}">` (cached; carries the ring icons' CSS too) or
++ * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside })
* + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.code({ title, lede, samples: [{ label, lang, code }] })
@@@ -183,4 +184,4 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition:none}}
`;
--module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, esc };
++module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, STYLESHEET: 'showcase.css', esc };
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 9df4191..02d7e32 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -31,4 +31,9 @@ assert.ok(code.includes('class="language-js"') && code.includes('&lt;script&gt;x
const d = sc.demo({ title: 'Try it', iframe: { src: 'https://x.example/?a=1&b=2', height: 5000 } });
assert.ok(d.includes('loading="lazy"') && d.includes('height:900px') && d.includes('&amp;b=2'), 'a demo frame is lazy and bounded');
assert.ok(typeof sc.CSS === 'string' && sc.CSS.includes('.sc-card') && sc.CSS.includes('prefers-reduced-motion'));
++const { execFileSync } = require('child_process');
++execFileSync(process.execPath, [require('path').join(__dirname, '..', 'scripts', 'build-styles.js'), '--check'], { stdio: 'inherit' });
++const files = require('../files');
++assert.ok(files.isBrowserFile('showcase.css') && files.STYLES.includes('showcase.css') && !files.BROWSER.includes('showcase.css'), 'served as a stylesheet, not a script');
++assert.strictEqual(sc.STYLESHEET, 'showcase.css');
console.log('showcase: all checks passed');