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.
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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`,@@@ -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);},};@@@ -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",nnew file mode 100644@@@ -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);@@@ -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 }));nnew file mode 100644@@@ -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}}@@@ -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 };@@@ -31,4 +31,9 @@ assert.ok(code.includes('class="language-js"') && code.includes('<script>xconst 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('&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');
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 2.3.0 — 2026-09-29
- `openvibe-shared/showcase` (plan T11, D92): the sections a product's home page is built from — `hero`, `features`,
- `openvibe-shared/showcase` (plan T11, D92): the sections a product's home page is built from — `hero`, `features`,
@@ -38,12 +38,19 @@ const BROWSER = Object.freeze([
@@ -38,12 +38,19 @@ const BROWSER = Object.freeze([
'openvibe-sw.js', // Web Push service worker
'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 = {
module.exports = {
dir: __dirname,
dir: __dirname,
BROWSER,
BROWSER,
- isBrowserFile: (name) => BROWSER.includes(String(name)),
+ STYLES,
+ isBrowserFile: served,
path: (name) => {
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);
return path.join(__dirname, name);
},
},
};
};
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -70,6 +70,7 @@
@@ -70,6 +70,7 @@
},
},
"files": [
"files": [
"*.js",
"*.js",
+ "*.css",
"analytics/",
"analytics/",
"docs/schemas/",
"docs/schemas/",
"scripts/",
"scripts/",
@@ -79,7 +80,8 @@
@@ -79,7 +80,8 @@
"test": "node test/run.js",
"test": "node test/run.js",
"size": "node scripts/size-report.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
"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": {
"repository": {
"type": "git",
"type": "git",
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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);
@@ -38,7 +38,7 @@ function handler() {
@@ -38,7 +38,7 @@ function handler() {
if (!files.isBrowserFile(name)) return next();
if (!files.isBrowserFile(name)) return next();
const hash = hashOf(name);
const hash = hashOf(name);
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
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,
// 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.
// 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 }));
res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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}}
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
* tokens, so every product's home reads as one family:
* tokens, so every product's home reads as one family:
*
*
* const sc = require('openvibe-shared/showcase');
* 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.features({ id, title, lede, items: [{ icon, title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.code({ title, lede, samples: [{ label, lang, code }] })
* + 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
@@ -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}}
@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 };
@@ -31,4 +31,9 @@ assert.ok(code.includes('class="language-js"') && code.includes('<script>x
@@ -31,4 +31,9 @@ assert.ok(code.includes('class="language-js"') && code.includes('<script>x
const d = sc.demo({ title: 'Try it', iframe: { src: 'https://x.example/?a=1&b=2', height: 5000 } });
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('&b=2'), 'a demo frame is lazy and bounded');
assert.ok(d.includes('loading="lazy"') && d.includes('height:900px') && d.includes('&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'));
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');
console.log('showcase: all checks passed');