openvibers/openvibe.design Commits

Changes: every shared release, and the component pages it touches (#261)

OP OpenVibers committed 2 days ago · 98375fb91b · 71 files, +1343 −16

* Design: every shared release, and the components it changed

* Merge remote-tracking branch 'origin/main' into design/changes-gallery

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 0d1c005..2e3e3ad 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -69,7 +69,9 @@
"Design Studio chat edit",
"Design Studio click edit",
"Design Studio versions and undo/redo",
-- "Design tokens as files (JSON, CSS, Tailwind, Style Dictionary, Swift, Kotlin, Android XML)"
++ "Design tokens as files (JSON, CSS, Tailwind, Style Dictionary, Swift, Kotlin, Android XML)",
++ "The changes archive: every openvibe-shared release from the pinned changelog, its component pages and rule numbers",
++ "Component pages for the bar's brand, the search pill, the quest card, the avatar, the player, soft pages, icon ink and the footer"
],
"updated": "2026-11-10"
}
ddiff --git a/public/changes/4.195.0/menu-1280-dark.webp b/public/changes/4.195.0/menu-1280-dark.webp
nnew file mode 100644
iindex 0000000..cbdc271
BBinary files /dev/null and b/public/changes/4.195.0/menu-1280-dark.webp differ
ddiff --git a/public/changes/4.195.0/menu-1280-light.webp b/public/changes/4.195.0/menu-1280-light.webp
nnew file mode 100644
iindex 0000000..1c54b40
BBinary files /dev/null and b/public/changes/4.195.0/menu-1280-light.webp differ
ddiff --git a/public/changes/4.195.0/menu-390-dark.webp b/public/changes/4.195.0/menu-390-dark.webp
nnew file mode 100644
iindex 0000000..a6ec799
BBinary files /dev/null and b/public/changes/4.195.0/menu-390-dark.webp differ
ddiff --git a/public/changes/4.195.0/menu-390-light.webp b/public/changes/4.195.0/menu-390-light.webp
nnew file mode 100644
iindex 0000000..6a8876e
BBinary files /dev/null and b/public/changes/4.195.0/menu-390-light.webp differ
ddiff --git a/public/changes/4.195.0/you-home-1280-dark.webp b/public/changes/4.195.0/you-home-1280-dark.webp
nnew file mode 100644
iindex 0000000..47e6a8f
BBinary files /dev/null and b/public/changes/4.195.0/you-home-1280-dark.webp differ
ddiff --git a/public/changes/4.195.0/you-home-1280-light.webp b/public/changes/4.195.0/you-home-1280-light.webp
nnew file mode 100644
iindex 0000000..557746f
BBinary files /dev/null and b/public/changes/4.195.0/you-home-1280-light.webp differ
ddiff --git a/public/changes/4.195.0/you-home-390-dark.webp b/public/changes/4.195.0/you-home-390-dark.webp
nnew file mode 100644
iindex 0000000..31f32b7
BBinary files /dev/null and b/public/changes/4.195.0/you-home-390-dark.webp differ
ddiff --git a/public/changes/4.195.0/you-home-390-light.webp b/public/changes/4.195.0/you-home-390-light.webp
nnew file mode 100644
iindex 0000000..5db055a
BBinary files /dev/null and b/public/changes/4.195.0/you-home-390-light.webp differ
ddiff --git a/public/changes/4.196.0/dock-1280-dark.webp b/public/changes/4.196.0/dock-1280-dark.webp
nnew file mode 100644
iindex 0000000..e51dea4
BBinary files /dev/null and b/public/changes/4.196.0/dock-1280-dark.webp differ
ddiff --git a/public/changes/4.196.0/dock-1280-light.webp b/public/changes/4.196.0/dock-1280-light.webp
nnew file mode 100644
iindex 0000000..5fed426
BBinary files /dev/null and b/public/changes/4.196.0/dock-1280-light.webp differ
ddiff --git a/public/changes/4.196.0/dock-390-dark.webp b/public/changes/4.196.0/dock-390-dark.webp
nnew file mode 100644
iindex 0000000..f5d90d5
BBinary files /dev/null and b/public/changes/4.196.0/dock-390-dark.webp differ
ddiff --git a/public/changes/4.196.0/dock-390-light.webp b/public/changes/4.196.0/dock-390-light.webp
nnew file mode 100644
iindex 0000000..c2107e5
BBinary files /dev/null and b/public/changes/4.196.0/dock-390-light.webp differ
ddiff --git a/public/changes/4.197.0/footer-1280-dark.webp b/public/changes/4.197.0/footer-1280-dark.webp
nnew file mode 100644
iindex 0000000..010b7f1
BBinary files /dev/null and b/public/changes/4.197.0/footer-1280-dark.webp differ
ddiff --git a/public/changes/4.197.0/footer-1280-light.webp b/public/changes/4.197.0/footer-1280-light.webp
nnew file mode 100644
iindex 0000000..2814470
BBinary files /dev/null and b/public/changes/4.197.0/footer-1280-light.webp differ
ddiff --git a/public/changes/4.197.0/footer-390-dark.webp b/public/changes/4.197.0/footer-390-dark.webp
nnew file mode 100644
iindex 0000000..79d85ad
BBinary files /dev/null and b/public/changes/4.197.0/footer-390-dark.webp differ
ddiff --git a/public/changes/4.197.0/footer-390-light.webp b/public/changes/4.197.0/footer-390-light.webp
nnew file mode 100644
iindex 0000000..e827f8d
BBinary files /dev/null and b/public/changes/4.197.0/footer-390-light.webp differ
ddiff --git a/public/changes/4.198.0/dock-1280-dark.webp b/public/changes/4.198.0/dock-1280-dark.webp
nnew file mode 100644
iindex 0000000..e51dea4
BBinary files /dev/null and b/public/changes/4.198.0/dock-1280-dark.webp differ
ddiff --git a/public/changes/4.198.0/dock-1280-light.webp b/public/changes/4.198.0/dock-1280-light.webp
nnew file mode 100644
iindex 0000000..5fed426
BBinary files /dev/null and b/public/changes/4.198.0/dock-1280-light.webp differ
ddiff --git a/public/changes/4.198.0/dock-390-dark.webp b/public/changes/4.198.0/dock-390-dark.webp
nnew file mode 100644
iindex 0000000..f5d90d5
BBinary files /dev/null and b/public/changes/4.198.0/dock-390-dark.webp differ
ddiff --git a/public/changes/4.198.0/dock-390-light.webp b/public/changes/4.198.0/dock-390-light.webp
nnew file mode 100644
iindex 0000000..c2107e5
BBinary files /dev/null and b/public/changes/4.198.0/dock-390-light.webp differ
ddiff --git a/public/changes/4.198.0/menu-1280-dark.webp b/public/changes/4.198.0/menu-1280-dark.webp
nnew file mode 100644
iindex 0000000..cbdc271
BBinary files /dev/null and b/public/changes/4.198.0/menu-1280-dark.webp differ
ddiff --git a/public/changes/4.198.0/menu-1280-light.webp b/public/changes/4.198.0/menu-1280-light.webp
nnew file mode 100644
iindex 0000000..1c54b40
BBinary files /dev/null and b/public/changes/4.198.0/menu-1280-light.webp differ
ddiff --git a/public/changes/4.198.0/menu-390-dark.webp b/public/changes/4.198.0/menu-390-dark.webp
nnew file mode 100644
iindex 0000000..a6ec799
BBinary files /dev/null and b/public/changes/4.198.0/menu-390-dark.webp differ
ddiff --git a/public/changes/4.198.0/menu-390-light.webp b/public/changes/4.198.0/menu-390-light.webp
nnew file mode 100644
iindex 0000000..6a8876e
BBinary files /dev/null and b/public/changes/4.198.0/menu-390-light.webp differ
ddiff --git a/public/changes/4.199.0/avatar-1280-dark.webp b/public/changes/4.199.0/avatar-1280-dark.webp
nnew file mode 100644
iindex 0000000..43c6f9d
BBinary files /dev/null and b/public/changes/4.199.0/avatar-1280-dark.webp differ
ddiff --git a/public/changes/4.199.0/avatar-1280-light.webp b/public/changes/4.199.0/avatar-1280-light.webp
nnew file mode 100644
iindex 0000000..21b80d3
BBinary files /dev/null and b/public/changes/4.199.0/avatar-1280-light.webp differ
ddiff --git a/public/changes/4.199.0/avatar-390-dark.webp b/public/changes/4.199.0/avatar-390-dark.webp
nnew file mode 100644
iindex 0000000..77b72e2
BBinary files /dev/null and b/public/changes/4.199.0/avatar-390-dark.webp differ
ddiff --git a/public/changes/4.199.0/avatar-390-light.webp b/public/changes/4.199.0/avatar-390-light.webp
nnew file mode 100644
iindex 0000000..1ab62db
BBinary files /dev/null and b/public/changes/4.199.0/avatar-390-light.webp differ
ddiff --git a/public/changes/4.199.0/soft-pages-1280-dark.webp b/public/changes/4.199.0/soft-pages-1280-dark.webp
nnew file mode 100644
iindex 0000000..df69e07
BBinary files /dev/null and b/public/changes/4.199.0/soft-pages-1280-dark.webp differ
ddiff --git a/public/changes/4.199.0/soft-pages-1280-light.webp b/public/changes/4.199.0/soft-pages-1280-light.webp
nnew file mode 100644
iindex 0000000..221a4d2
BBinary files /dev/null and b/public/changes/4.199.0/soft-pages-1280-light.webp differ
ddiff --git a/public/changes/4.199.0/soft-pages-390-dark.webp b/public/changes/4.199.0/soft-pages-390-dark.webp
nnew file mode 100644
iindex 0000000..d643d3b
BBinary files /dev/null and b/public/changes/4.199.0/soft-pages-390-dark.webp differ
ddiff --git a/public/changes/4.199.0/soft-pages-390-light.webp b/public/changes/4.199.0/soft-pages-390-light.webp
nnew file mode 100644
iindex 0000000..49d4405
BBinary files /dev/null and b/public/changes/4.199.0/soft-pages-390-light.webp differ
ddiff --git a/public/changes/4.200.0/brand-1280-dark.webp b/public/changes/4.200.0/brand-1280-dark.webp
nnew file mode 100644
iindex 0000000..18e679e
BBinary files /dev/null and b/public/changes/4.200.0/brand-1280-dark.webp differ
ddiff --git a/public/changes/4.200.0/brand-1280-light.webp b/public/changes/4.200.0/brand-1280-light.webp
nnew file mode 100644
iindex 0000000..90b897b
BBinary files /dev/null and b/public/changes/4.200.0/brand-1280-light.webp differ
ddiff --git a/public/changes/4.200.0/brand-390-dark.webp b/public/changes/4.200.0/brand-390-dark.webp
nnew file mode 100644
iindex 0000000..ecf6d4f
BBinary files /dev/null and b/public/changes/4.200.0/brand-390-dark.webp differ
ddiff --git a/public/changes/4.200.0/brand-390-light.webp b/public/changes/4.200.0/brand-390-light.webp
nnew file mode 100644
iindex 0000000..d5003ac
BBinary files /dev/null and b/public/changes/4.200.0/brand-390-light.webp differ
ddiff --git a/public/changes/4.200.0/dock-1280-dark.webp b/public/changes/4.200.0/dock-1280-dark.webp
nnew file mode 100644
iindex 0000000..e51dea4
BBinary files /dev/null and b/public/changes/4.200.0/dock-1280-dark.webp differ
ddiff --git a/public/changes/4.200.0/dock-1280-light.webp b/public/changes/4.200.0/dock-1280-light.webp
nnew file mode 100644
iindex 0000000..5fed426
BBinary files /dev/null and b/public/changes/4.200.0/dock-1280-light.webp differ
ddiff --git a/public/changes/4.200.0/dock-390-dark.webp b/public/changes/4.200.0/dock-390-dark.webp
nnew file mode 100644
iindex 0000000..f5d90d5
BBinary files /dev/null and b/public/changes/4.200.0/dock-390-dark.webp differ
ddiff --git a/public/changes/4.200.0/dock-390-light.webp b/public/changes/4.200.0/dock-390-light.webp
nnew file mode 100644
iindex 0000000..c2107e5
BBinary files /dev/null and b/public/changes/4.200.0/dock-390-light.webp differ
ddiff --git a/public/changes/4.200.0/menu-1280-dark.webp b/public/changes/4.200.0/menu-1280-dark.webp
nnew file mode 100644
iindex 0000000..cbdc271
BBinary files /dev/null and b/public/changes/4.200.0/menu-1280-dark.webp differ
ddiff --git a/public/changes/4.200.0/menu-1280-light.webp b/public/changes/4.200.0/menu-1280-light.webp
nnew file mode 100644
iindex 0000000..1c54b40
BBinary files /dev/null and b/public/changes/4.200.0/menu-1280-light.webp differ
ddiff --git a/public/changes/4.200.0/menu-390-dark.webp b/public/changes/4.200.0/menu-390-dark.webp
nnew file mode 100644
iindex 0000000..a6ec799
BBinary files /dev/null and b/public/changes/4.200.0/menu-390-dark.webp differ
ddiff --git a/public/changes/4.200.0/menu-390-light.webp b/public/changes/4.200.0/menu-390-light.webp
nnew file mode 100644
iindex 0000000..6a8876e
BBinary files /dev/null and b/public/changes/4.200.0/menu-390-light.webp differ
ddiff --git a/public/changes/4.200.0/search-pill-1280-dark.webp b/public/changes/4.200.0/search-pill-1280-dark.webp
nnew file mode 100644
iindex 0000000..4afe16f
BBinary files /dev/null and b/public/changes/4.200.0/search-pill-1280-dark.webp differ
ddiff --git a/public/changes/4.200.0/search-pill-1280-light.webp b/public/changes/4.200.0/search-pill-1280-light.webp
nnew file mode 100644
iindex 0000000..c35eb50
BBinary files /dev/null and b/public/changes/4.200.0/search-pill-1280-light.webp differ
ddiff --git a/public/changes/4.200.0/search-pill-390-dark.webp b/public/changes/4.200.0/search-pill-390-dark.webp
nnew file mode 100644
iindex 0000000..2d940f6
BBinary files /dev/null and b/public/changes/4.200.0/search-pill-390-dark.webp differ
ddiff --git a/public/changes/4.200.0/search-pill-390-light.webp b/public/changes/4.200.0/search-pill-390-light.webp
nnew file mode 100644
iindex 0000000..f8c6c2b
BBinary files /dev/null and b/public/changes/4.200.0/search-pill-390-light.webp differ
ddiff --git a/public/changes/avatar-sample.svg b/public/changes/avatar-sample.svg
nnew file mode 100644
iindex 0000000..95497a2
---- /dev/null
++++ b/public/changes/avatar-sample.svg
@@@ -0,0 +1,9 @@
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
++<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3b5bdb"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs>
++<rect width="64" height="64" rx="32" fill="url(#g)"/>
++<circle cx="32" cy="25" r="11" fill="#f4d7c5"/>
++<path d="M11 62c2-14 10-21 21-21s19 7 21 21z" fill="#f4d7c5"/>
++<path d="M21 22c0-9 5-13 11-13s11 4 11 13c-5-2-8-6-11-6s-6 4-11 6z" fill="#2b1b12"/>
++<circle cx="28" cy="25" r="1.4" fill="#2b1b12"/><circle cx="36" cy="25" r="1.4" fill="#2b1b12"/>
++<path d="M28 30c2 1.6 6 1.6 8 0" stroke="#a33a2a" stroke-width="1.4" fill="none" stroke-linecap="round"/>
++</svg>
ddiff --git a/public/changes/specimen-player.mp4 b/public/changes/specimen-player.mp4
nnew file mode 100644
iindex 0000000..91b34fd
BBinary files /dev/null and b/public/changes/specimen-player.mp4 differ
ddiff --git a/public/css/changes.css b/public/css/changes.css
nnew file mode 100644
iindex 0000000..d16c5e7
---- /dev/null
++++ b/public/css/changes.css
@@@ -0,0 +1,55 @@
++/* /changes: the shared release archive. A timeline of releases, their entry as written, the pages and rule numbers
++ they touch, and the plates the repo holds. Plain tokens, no new colour. */
++.chg { max-width: 78rem; margin: 0 auto; padding: 0 20px 64px; }
++.chg-hero { padding-top: 40px; }
++.chg-hero .sc-stats, .chg-hero .ovk-stats { margin: 20px 0 18px; }
++.chg-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 8px; }
++.chg-hero .ds-fine { max-width: 62ch; }
++.chg-index { margin: 22px 0 8px; padding: 16px 0 4px; border-top: 1px solid var(--border, #2c2c38); }
++.chg-index-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 6px 14px; margin: 6px 0 8px; }
++.chg-index-row a { display: flex; gap: 8px; align-items: baseline; padding: 6px 8px; border-radius: 8px; color: var(--text-primary, #e6edf7); text-decoration: none; }
++.chg-index-row a:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
++.chg-index-row b { font-variant-numeric: tabular-nums; color: var(--accent, #5b9dff); flex: none; }
++.chg-index-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text-muted, #9aa6b8); }
++.chg-list { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
++.chg-release { padding: 22px 0 26px; border-top: 1px solid var(--border, #2c2c38); scroll-margin-top: 72px; }
++.chg-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 18px; }
++.chg-head > div { display: flex; align-items: baseline; gap: 12px; }
++.chg-head h2 { margin: 0; font-size: 22px; line-height: 1.2; }
++.chg-head h2 a { color: var(--text-primary, #e6edf7); text-decoration: none; font-variant-numeric: tabular-nums; }
++.chg-head time { color: var(--text-muted, #9aa6b8); font-size: 13px; }
++.chg-title { flex-basis: 100%; margin: 2px 0 0; color: var(--text-primary, #e6edf7); font-weight: 600; max-width: 80ch; }
++.chg-entry { max-width: 82ch; margin-top: 10px; color: var(--text-secondary, #c3ccdb); font-size: 14.5px; line-height: 1.62; }
++.chg-entry p { margin: 9px 0; }
++.chg-entry ul { margin: 8px 0 10px; padding-left: 20px; }
++.chg-entry ul ul { margin: 5px 0 6px; }
++.chg-entry li { margin: 4px 0; }
++.chg-entry strong { color: var(--text-primary, #e6edf7); }
++.chg-entry code { font-size: 12.5px; padding: 1px 4px; border-radius: 4px; background: var(--bg-tertiary, #1b2230); color: var(--text-primary, #e6edf7); }
++.chg-entry h3, .chg-entry h4 { margin: 16px 0 6px; font-size: 15px; color: var(--text-primary, #e6edf7); }
++.chg-entry a { color: var(--accent, #5b9dff); }
++.chg-label { display: block; margin: 0 0 6px; font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #9aa6b8); }
++.chg-refs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 12px 0 0; }
++.chg-refs .chg-label { margin: 0 4px 0 0; }
++.chg-chip { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 4px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); font-size: 12.5px; text-decoration: none; }
++.chg-chip:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.chg-chip--rule { border-color: color-mix(in srgb, var(--text-muted, #9aa6b8) 30%, transparent); background: color-mix(in srgb, var(--text-muted, #9aa6b8) 8%, transparent); }
++.chg-chip--rule b { color: var(--accent, #5b9dff); font-variant-numeric: tabular-nums; }
++.chg-shots { margin-top: 16px; }
++.chg-shot-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
++.chg-shot { margin: 0; border: 1px solid var(--border, #2c2c38); border-radius: 12px; overflow: hidden; background: var(--bg-card, #141a24); }
++.chg-shot img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; object-position: top center; background: var(--bg-primary, #0d131d); }
++.chg-shot figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; padding: 8px 10px 9px; font-size: 12px; }
++.chg-shot figcaption b { font-size: 12.5px; }
++.chg-shot figcaption span { display: flex; flex-wrap: wrap; gap: 6px; }
++.chg-shot figcaption a { color: var(--text-muted, #9aa6b8); text-decoration: none; }
++.chg-shot figcaption a:hover { color: var(--accent, #5b9dff); }
++.chg-foot { margin-top: 26px; padding-top: 18px; border-top: 1px solid var(--border, #2c2c38); color: var(--text-muted, #9aa6b8); font-size: 13px; }
++.chg-foot .ovk-btn { margin-right: 8px; margin-top: 8px; }
++@media (max-width: 640px) {
++ .chg { padding: 0 14px 48px; }
++ .chg-hero { padding-top: 24px; }
++ .chg-entry { font-size: 14px; }
++ .chg-shot-row { grid-template-columns: 1fr 1fr; gap: 8px; }
++ .chg-shot figcaption { display: block; }
++}
ddiff --git a/public/css/rules.css b/public/css/rules.css
iindex 2517fb7..ac093fa 100644
---- a/public/css/rules.css
++++ b/public/css/rules.css
@@@ -38,6 +38,7 @@
.rl-rule h3{margin:0;font-size:1.06rem;line-height:1.35;font-weight:750}
.rl-rule h3 a{text-decoration:none}
.rl-rule h3 a:hover{text-decoration:underline;text-underline-offset:3px}
++.rl-num{display:inline-grid;place-items:center;min-width:1.7em;height:1.7em;margin-right:.5em;border-radius:999px;background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--accent);font-size:.72rem;font-weight:800;font-variant-numeric:tabular-nums;vertical-align:.08em}
.rl-rule time{flex:none;color:var(--text-muted);font-size:.8rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.rl-detail{margin:0;color:var(--text-secondary);line-height:1.55}
.rl-why{margin:0;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--text-primary) 4%,transparent);color:var(--text-secondary);font-size:.9rem;line-height:1.45}
ddiff --git a/public/css/specimens.css b/public/css/specimens.css
nnew file mode 100644
iindex 0000000..96b685f
---- /dev/null
++++ b/public/css/specimens.css
@@@ -0,0 +1,180 @@
++/* The component note pages and their specimens. Plain tokens; the one scoped copy of a shared part is named (the
++ brand button until the pinned package carries it, the quest card while its module is in review). */
++
++/* ── The note page ─────────────────────────────────────────── */
++.cn { max-width: 78rem; padding-bottom: 56px; }
++.cn .ds-page-h { padding-top: 30px; }
++.cn-meta { margin: 10px 0 0; color: var(--text-muted, #9aa6b8); font-size: 13.5px; }
++.cn-meta a { color: var(--accent, #5b9dff); }
++.cn-spec { margin: 26px 0; }
++.cn-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
++.cn-head h2 { margin: 0; font-size: 19px; }
++.cn-spec [data-specimen] { margin-top: 12px; }
++.cn-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px; margin-top: 8px; }
++.cn-cols h2 { font-size: 17px; margin: 0 0 8px; }
++.cn-cols p { margin: 8px 0; color: var(--text-secondary, #c3ccdb); font-size: 14.5px; line-height: 1.6; }
++.cn-shipped blockquote { margin: 14px 0 0; padding: 10px 14px; border-left: 3px solid var(--accent, #5b9dff); color: var(--text-muted, #9aa6b8); font-size: 13.5px; line-height: 1.55; background: color-mix(in srgb, var(--accent, #3b82f6) 5%, transparent); border-radius: 0 8px 8px 0; }
++.cn-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.cn-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 4px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); font-size: 12.5px; text-decoration: none; }
++.cn-chip:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
++.cn-chip--rule { border-color: color-mix(in srgb, var(--text-muted, #9aa6b8) 28%, transparent); background: color-mix(in srgb, var(--text-muted, #9aa6b8) 7%, transparent); }
++.cn-chip--rule b { color: var(--accent, #5b9dff); font-variant-numeric: tabular-nums; }
++.cn-foot { margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--border, #2c2c38); color: var(--text-muted, #9aa6b8); font-size: 13px; }
++.cn-foot a { color: var(--accent, #5b9dff); }
++
++/* ── A specimen ────────────────────────────────────────────── */
++.spec { padding: 20px; border: 1px solid var(--border, #2c2c38); border-radius: 16px; background: var(--bg-card, #141a24); color: var(--text-primary, #e6edf7); }
++.spec-note { max-width: 76ch; margin: 16px 0 0; color: var(--text-muted, #9aa6b8); font-size: 13.5px; line-height: 1.6; }
++.spec-note b, .spec-note code { color: var(--text-primary, #e6edf7); }
++.spec-split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; margin-top: 18px; }
++.spec-split b { font-size: 13.5px; }
++.spec-split p { margin: 5px 0 0; color: var(--text-muted, #9aa6b8); font-size: 13px; line-height: 1.55; }
++
++/* The bar's brand. Copied from openvibe-shared/navbar.js's `.ovnav-brand` until the pinned release carries it; scoped
++ to the specimen so it can never leak into the page's own bar. */
++.spec .ovnav-brand { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; gap: 7px; min-width: 0; height: 40px; padding: 0 12px 0 4px; border-radius: 12px; cursor: pointer; color: inherit; transition: background-color .15s, box-shadow .26s cubic-bezier(.2,1.4,.3,1); }
++.spec .ovnav-brand::before { content: ""; position: absolute; inset: -3px 0; }
++.spec .ovnav-brand::after { content: ""; flex: none; width: 6px; height: 6px; margin: 0 0 3px 2px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .6; transition: transform .2s; }
++.spec .ovnav-brand:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
++.spec .ovnav-brand[aria-expanded="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 17%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 32%, transparent); }
++.spec .ovnav-brand[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); }
++.spec .ovnav-brand:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++.spec .ovnav-brand .flame { display: grid; place-items: center; flex: none; width: 42px; height: 42px; }
++.spec .ovnav-brand .flame svg { width: 42px; height: 26px; display: block; }
++.spec .ovnav-brand .name { display: inline-flex; align-items: baseline; min-width: 0; font: 800 16.5px/1 var(--ov-font, system-ui, sans-serif); letter-spacing: -.01em; white-space: nowrap; }
++.spec .ovnav-brand .b-dot { opacity: .55; margin: 0 1px; }
++.spec .ovnav-brand .b-tld { opacity: .78; }
++.spec-bar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
++.spec-orb { display: inline-flex; align-items: center; gap: 3px; width: 34px; height: 34px; justify-content: center; border-radius: 11px; border: 1px solid var(--border, #2c2c38); background: var(--bg-secondary, #1a2130); }
++.spec-orb i { width: 4px; height: 4px; border-radius: 50%; background: var(--text-muted, #9aa6b8); }
++.spec-drop { max-width: 300px; margin-top: 14px; padding: 8px; border: 1px solid var(--border, #2c2c38); border-radius: 12px; background: var(--bg-secondary, #1a2130); box-shadow: 0 18px 44px rgba(0, 0, 0, .3); }
++.spec-drop-h { margin: 4px 8px 6px; color: var(--text-muted, #9aa6b8); font-size: 11px; letter-spacing: .07em; text-transform: uppercase; }
++.spec-drop a { display: flex; align-items: center; gap: 9px; min-height: 36px; padding: 0 8px; border-radius: 8px; color: var(--text-primary, #e6edf7); font-size: 14px; text-decoration: none; }
++.spec-drop a:hover, .spec-drop a[aria-current="page"] { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++.spec-drop a .ovk-i { width: 16px; height: 16px; color: var(--text-muted, #9aa6b8); }
++.spec-drop-all { margin-top: 4px; border-top: 1px solid var(--border, #2c2c38); border-radius: 0 0 8px 8px !important; }
++
++/* The menu and the search pill */
++.spec-sheet { max-width: 620px; margin: 0 auto; padding: 12px; border-radius: 18px; border: 1px solid var(--border, #2c2c38); background: var(--bg-secondary, #1a2130); }
++.spec-places { display: grid; grid-template-columns: repeat(5, 1fr); gap: 2px; margin-bottom: 10px; }
++.spec-places button { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; min-height: 62px; padding: 6px 4px; border-radius: 10px; color: var(--text-muted, #9aa6b8); font: 700 12px/1.15 var(--ov-font, system-ui, sans-serif); text-align: center; cursor: pointer; position: relative; }
++.spec-places button .ovk-i { width: 21px; height: 21px; }
++.spec-places button:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); color: var(--text-primary, #e6edf7); }
++.spec-places button.is-on { color: var(--accent, #6ea8ff); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++.spec-places small { font-size: 9.5px; font-weight: 600; opacity: .8; white-space: nowrap; }
++.spec-badge { position: absolute; top: 4px; right: calc(50% - 22px); display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 999px; background: #e5484d; color: #fff; font: 800 10px/1 var(--ov-font, system-ui, sans-serif); font-style: normal; }
++.spec-search { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 6px 0 12px; border: 1px solid var(--border, #2c2c38); border-radius: 999px; background: var(--bg-primary, #0d131d); }
++.spec-search > .ovk-i { width: 17px; height: 17px; flex: none; color: var(--text-muted, #9aa6b8); }
++.spec-search input { flex: 1; min-width: 0; height: 40px; border: 0; outline: 0; background: transparent; color: var(--text-primary, #e6edf7); font: 500 14px/1 var(--ov-font, system-ui, sans-serif); }
++.spec-search input::placeholder { color: var(--text-muted, #9aa6b8); }
++.spec-actions { display: inline-flex; align-items: center; gap: 2px; flex: none; }
++.spec-actions button { all: unset; box-sizing: border-box; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 999px; color: var(--text-muted, #9aa6b8); cursor: pointer; }
++.spec-actions button .ovk-i { width: 16px; height: 16px; }
++.spec-actions button:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); }
++.spec-close { color: var(--text-primary, #e6edf7) !important; }
++.spec-search--big { max-width: 640px; margin: 0 auto; min-height: 54px; padding-left: 16px; }
++.spec-search--big .spec-actions button { width: 40px; height: 40px; }
++
++/* The avatar */
++.spec-avatars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
++.spec-avatars figure { margin: 0; text-align: center; }
++.spec-avatars figcaption { margin-top: 8px; color: var(--text-muted, #9aa6b8); font-size: 12.5px; line-height: 1.4; }
++.spec-av { display: inline-grid; place-items: center; width: 64px; height: 64px; }
++.spec-av .ovk-mark { width: 64px; height: 64px; font-size: 24px; }
++.spec-av--broken { position: relative; }
++.spec-av-shot { position: absolute; inset: 0; border-radius: 50%; border: 1px dashed color-mix(in srgb, var(--text-muted, #9aa6b8) 55%, transparent); }
++.spec-av-init { position: relative; display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: #3b5bdb; color: #fff; font: 800 26px/1 var(--ov-font, system-ui, sans-serif); }
++
++/* The player */
++.spec-player { max-width: 720px; margin: 0 auto; border-radius: 14px; overflow: hidden; background: #000; }
++.spec-player video { display: block; width: 100%; height: auto; }
++
++/* Soft pages */
++.spec-steps { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; counter-reset: softstep; }
++.spec-steps li { display: flex; gap: 12px; align-items: flex-start; }
++.spec-step-n { display: grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent, #6ea8ff); font: 800 13px/1 var(--ov-font, system-ui, sans-serif); }
++.spec-steps b { font-size: 14px; }
++.spec-steps p { margin: 4px 0 0; color: var(--text-muted, #9aa6b8); font-size: 13.5px; line-height: 1.55; }
++.spec-steps code { color: var(--text-primary, #e6edf7); font-size: 12.5px; }
++
++/* Icon ink */
++.spec-tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; align-items: start; }
++.spec-tiles figure { margin: 0; text-align: center; }
++.spec-tiles figcaption { margin-top: 10px; color: var(--text-muted, #9aa6b8); font-size: 12.5px; line-height: 1.45; }
++.spec-tile { display: inline-grid; place-items: center; width: 112px; height: 112px; }
++.spec-tile--app svg { width: 112px; height: 112px; border-radius: 26px; }
++.spec-tile--box { border: 1px solid var(--border, #2c2c38); border-radius: 26px; background: var(--bg-primary, #0d131d); }
++.spec-tile--box svg { width: 58%; height: auto; }
++.spec-glyphs { display: grid; grid-template-columns: repeat(3, 44px); gap: 8px; justify-content: center; }
++.spec-glyphs span { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--border, #2c2c38); border-radius: 11px; background: var(--bg-primary, #0d131d); }
++.spec-glyphs .ovk-i { width: 22px; height: 22px; }
++
++/* The footer */
++.spec-footer { max-width: 620px; margin: 0 auto; padding: 16px 18px; border: 1px solid var(--border, #2c2c38); border-radius: 14px; background: var(--bg-primary, #0d131d); }
++.spec-online { display: flex; align-items: center; gap: 10px; margin: 0; }
++.spec-online b { font-size: 14px; }
++.spec-faces { display: inline-flex; gap: 3px; }
++.spec-faces .ovk-mark { width: 24px; height: 24px; font-size: 10px; }
++.spec-tip { margin: 10px 0 0; padding: 10px 12px; border: 1px solid var(--border, #2c2c38); border-radius: 10px; background: var(--bg-secondary, #1a2130); font-size: 13px; }
++.spec-tip b { display: block; font-size: 13px; }
++.spec-tip span { color: var(--text-muted, #9aa6b8); }
++.spec-footer .spec-fine { margin: 10px 0 0; }
++
++/* The player's phone row from the change in review (openvibe-shared/player.css, last-in-file phone block). */
++@media (max-width: 520px) {
++ .spec-player .ov-player-speed, .spec-player .ov-player-pip { display: none !important; }
++ .spec-player .ov-player-volume-slider { width: 64px !important; margin-right: 2px !important; }
++ .spec-player .ov-player-time { padding: 0 8px; font-size: 12px; }
++}
++@media (max-width: 380px) { .spec-player .ov-player-volume-slider { display: none !important; } }
++@media (max-width: 360px) { .spec-player .ov-player-dur { display: none !important; } }
++
++/* The quest card (its markup and CSS are the openvibe-shared/quest design; in review for the next release) */
++.ovq-quest { --ovq-a: var(--ovk-accent, var(--accent, #3b82f6)); --ovq-p: var(--ovk-panel, var(--bg-card, #121826)); --ovq-t: var(--ovk-text, var(--text-primary, #e8ecf3)); --ovq-m: var(--ovk-muted, var(--text-secondary, #93a0b4)); --ovq-ok: var(--ovk-ok, var(--success, #22c55e)); max-width: 520px; margin: 0 auto; border-radius: var(--ov-radius-2, 14px); background: color-mix(in srgb, var(--ovq-a) 7%, var(--ovq-p)); box-shadow: var(--ov-elev-3, 0 8px 24px rgba(0,0,0,.25)); color: var(--ovq-t); text-align: left; overflow: hidden; }
++.ovq-quest[hidden], .ovq-body[hidden], .ovq-chip[hidden] { display: none; }
++.ovq-head { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 64px; padding: 12px 14px; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; }
++.ovq-head:focus-visible, .ovq-steps a:focus-visible, .ovq-more:focus-visible { outline: 2px solid var(--ovq-a); outline-offset: 2px; }
++.ovq-icon, .ovq-site-icon { display: grid; place-items: center; flex: none; width: 32px; height: 32px; color: var(--ovq-a); }
++.ovq-titles { display: flex; flex: 1; flex-direction: column; min-width: 0; }
++.ovq-kicker { color: color-mix(in srgb, var(--ovq-a) 78%, var(--ovq-t)); font-size: .66rem; font-weight: 800; letter-spacing: .09em; }
++.ovq-title { font-size: 1rem; font-weight: 800; line-height: 1.3; text-wrap: balance; }
++.ovq-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 160px; margin-top: 7px; }
++.ovq-bar i { height: 4px; border-radius: 999px; background: color-mix(in srgb, var(--ovq-t) 12%, transparent); }
++.ovq-quest[data-progress="1"] .ovq-bar i:first-child, .ovq-quest[data-progress="2"] .ovq-bar i:nth-child(-n+2), .ovq-quest[data-progress="3"] .ovq-bar i { background: var(--ovq-a); }
++.ovq-quest[data-progress="3"] .ovq-bar i { background: var(--ovq-ok); }
++.ovq-chip { display: inline-flex; flex: none; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 999px; background: color-mix(in srgb, #f97316 16%, transparent); color: color-mix(in srgb, #f97316 78%, var(--ovq-t)); font-size: .78rem; font-weight: 800; }
++.ovq-chev { color: var(--ovq-m); transition: transform .2s; }
++.ovq-quest.open .ovq-chev { transform: rotate(180deg); }
++.ovq-body { padding: 0 10px 10px; }
++.ovq-story { margin: -2px 4px 8px; color: var(--ovq-m); font-size: .82rem; line-height: 1.45; }
++.ovq-steps { display: grid; gap: 2px; list-style: none; padding: 0; margin: 0 0 8px; }
++.ovq-steps a { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px; border-radius: 12px; color: inherit; text-decoration: none; }
++.ovq-steps a:hover { background: color-mix(in srgb, var(--ovq-t) 6%, transparent); }
++.ovq-steps .is-next a { background: color-mix(in srgb, var(--ovq-a) 13%, transparent); }
++.ovq-step { display: flex; flex: 1; flex-direction: column; min-width: 0; font-size: .88rem; font-weight: 650; line-height: 1.3; }
++.ovq-step small { color: var(--ovq-m); font-size: .72rem; font-weight: 600; }
++.ovq-steps .is-next small, .ovq-more > i { color: color-mix(in srgb, var(--ovq-a) 78%, var(--ovq-t)); }
++.ovq-steps .is-done .ovq-step > span { color: var(--ovq-m); }
++.ovq-go { color: var(--ovq-m); font-size: .72rem; }
++.ovq-check { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--ovq-ok); color: #fff; font-size: .75rem; font-weight: 900; }
++.ovq-more { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--ovq-a) 15%, var(--ovq-p)); color: inherit; text-decoration: none; }
++.ovq-more:hover { background: color-mix(in srgb, var(--ovq-a) 22%, var(--ovq-p)); }
++.ovq-more-text { display: flex; flex: 1; flex-direction: column; min-width: 0; color: var(--ovq-m); font-size: .78rem; line-height: 1.35; }
++.ovq-more-text b { color: var(--ovq-t); font-size: .88rem; }
++.ovq-arrow { color: var(--ovk-accent, var(--accent, #60a5fa)); }
++
++@media (max-width: 700px) {
++ .cn-cols, .spec-split { grid-template-columns: 1fr; }
++ .spec-avatars, .spec-tiles { grid-template-columns: 1fr 1fr; }
++ .spec-avatars figure:last-child, .spec-tiles figure:last-child { grid-column: 1 / -1; }
++ .spec { padding: 14px; }
++ .spec-sheet { padding: 8px; }
++ .spec-places button { min-height: 54px; font-size: 11px; }
++ .spec-places button .ovk-i { width: 19px; height: 19px; }
++}
++
++/* The "what shipped" cards on /components/registry: the same shape as a definition card, but a note, not a contract. */
++.crl-note-link { display: grid; gap: 4px; color: var(--ovk-text, var(--text-primary, #e6edf7)); text-decoration: none; }
++.crl-note-link b { font-size: 1rem; }
++.crl-note-link span { color: var(--ovk-muted, var(--text-muted, #9aa6b8)); font-size: .87rem; line-height: 1.35; }
++.crl-note-link:hover b, .crl-note-link:focus-visible b { text-decoration: underline; }
ddiff --git a/scripts/capture-release.js b/scripts/capture-release.js
nnew file mode 100755
iindex 0000000..72d6c5d
---- /dev/null
++++ b/scripts/capture-release.js
@@@ -0,0 +1,136 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Screenshot the specimen pages a release names, at 390 and 1280, dark and light, as small webp plates.
++ *
++ * node scripts/capture-release.js 4.195.0 4.196.0 … (a local app boots itself, as the tests do)
++ * node scripts/capture-release.js --base https://openvibe.design 4.200.0
++ *
++ * One plate per component and width and scheme: public/changes/<version>/<component>-<390|1280>-<dark|light>.webp.
++ * The page is the component page Design already renders (its [data-specimen], else the demo stage); a release that
++ * names only a non-component page gets no plate. Chrome is the estate's own CDP launcher (tools/cdp.js); the plate is
++ * converted with cwebp. No network is asked for the changelog: server/changes.js reads the pinned package.
++ */
++const fs = require('fs');
++const path = require('path');
++const { execFileSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..');
++const OUT = path.join(ROOT, 'public', 'changes');
++const CDP = process.env.OV_CDP || path.resolve(ROOT, '..', 'tools', 'cdp.js');
++const WIDTHS = [390, 1280];
++const SCHEMES = ['dark', 'light'];
++const SELECTORS = ['[data-specimen]', '.cp-stage', '.cr-demo', 'main'];
++
++function usage() {
++ console.error('usage: node scripts/capture-release.js [--base URL] [--force] <version> [version …]');
++ process.exit(2);
++}
++
++function args() {
++ const out = { base: null, force: false, versions: [] };
++ const argv = process.argv.slice(2);
++ for (let i = 0; i < argv.length; i++) {
++ if (argv[i] === '--base') out.base = argv[++i];
++ else if (argv[i] === '--force') out.force = true;
++ else if (argv[i] === '--help' || argv[i] === '-h') usage();
++ else out.versions.push(argv[i]);
++ }
++ if (!out.versions.length) usage();
++ return out;
++}
++
++async function waitFor(page, selector, ms = 8000) {
++ const started = Date.now();
++ while (Date.now() - started < ms) {
++ const found = await page.eval(`!!document.querySelector(${JSON.stringify(selector)})`).catch(() => false);
++ if (found) return true;
++ await page.wait(200);
++ }
++ return false;
++}
++
++async function plate(page, url, width, scheme, file) {
++ await page.send('Emulation.setDeviceMetricsOverride', {
++ width, height: 900, deviceScaleFactor: 1, mobile: width < 500,
++ });
++ await page.go(url);
++ // The page's own theme comes from Network; the plate is the built-in theme, whichever the person wears.
++ await page.eval(`fetch('/scheme.css?scheme=${scheme}').then(function (r) { return r.text(); }).then(function (css) {
++ var s = document.getElementById('ov-capture-scheme') || document.createElement('style');
++ s.id = 'ov-capture-scheme'; s.textContent = css; document.head.appendChild(s);
++ // The bar and the footer are fixed; they would sit over a plate captured past the viewport.
++ var chrome = document.getElementById('ov-capture-chrome') || document.createElement('style');
++ chrome.id = 'ov-capture-chrome';
++ chrome.textContent = '#navbar-mount,.openvibe-navbar,.ovnav-appbar,.ovf-bar,#ov-footer{display:none!important}';
++ document.head.appendChild(chrome);
++ })`).catch(() => {});
++ let selector = null;
++ for (const sel of SELECTORS) {
++ if (await waitFor(page, sel, sel === 'main' ? 4000 : 8000)) { selector = sel; break; }
++ }
++ if (!selector) throw new Error(`${url}: no specimen`);
++ await page.wait(500);
++ const box = await page.eval(`(function () {
++ var el = document.querySelector(${JSON.stringify(selector)});
++ var r = el.getBoundingClientRect();
++ return { x: Math.max(0, r.left + window.scrollX), y: Math.max(0, r.top + window.scrollY), width: Math.min(${width}, r.width), height: r.height };
++ })()`);
++ const shot = await page.send('Page.captureScreenshot', {
++ format: 'png', captureBeyondViewport: true,
++ clip: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height), scale: 1 },
++ });
++ const png = `${file}.png`;
++ fs.writeFileSync(png, Buffer.from(shot.data, 'base64'));
++ const max = width === 390 ? 640 : 900;
++ execFileSync('cwebp', ['-quiet', '-q', '74', '-resize', String(max), '0', png, '-o', file]);
++ fs.unlinkSync(png);
++ return { file, bytes: fs.statSync(file).size };
++}
++
++(async () => {
++ const o = args();
++ const { releaseOf, componentsOf } = require('../server/changes');
++ const wanted = [];
++ for (const v of o.versions) {
++ const release = releaseOf(v);
++ if (!release) { console.error(`no release ${v} in the pinned changelog`); process.exitCode = 1; continue; }
++ const pages = componentsOf(release).filter((c) => c.href.startsWith('/components/'));
++ if (!pages.length) console.error(`${v}: no component page to shoot`);
++ for (const c of pages) wanted.push({ version: release.version, key: c.key, href: c.href });
++ }
++ if (!wanted.length) process.exit(1);
++
++ let booted = null;
++ let base = o.base;
++ if (!base) {
++ const { boot } = require('../test/helpers/boot');
++ booted = await boot();
++ base = booted.base;
++ console.log(`local app: ${base}`);
++ }
++ if (!fs.existsSync(CDP)) throw new Error(`the estate's CDP launcher is not at ${CDP}; set OV_CDP to tools/cdp.js`);
++ const cdp = require(CDP);
++ const browser = await cdp.launch({ width: 1280, height: 900 });
++ let made = 0;
++ try {
++ for (const item of wanted) {
++ const dir = path.join(OUT, item.version);
++ fs.mkdirSync(dir, { recursive: true });
++ for (const scheme of SCHEMES) {
++ for (const width of WIDTHS) {
++ const file = path.join(dir, `${item.key}-${width}-${scheme}.webp`);
++ if (!o.force && fs.existsSync(file)) { console.log(`kept ${path.relative(ROOT, file)}`); continue; }
++ const got = await plate(browser.page, `${base}${item.href}`, width, scheme, file);
++ made++;
++ console.log(`${path.relative(ROOT, file)} ${Math.round(got.bytes / 1024)} KB`);
++ }
++ }
++ }
++ } finally {
++ browser.close();
++ if (booted) await booted.close();
++ }
++ console.log(`\n${made} plate${made === 1 ? '' : 's'} written for ${[...new Set(wanted.map((x) => x.version))].join(', ')}`);
++ process.exit(process.exitCode || 0);
++})().catch((err) => { console.error(err && err.stack || err); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex 26a0fdd..9337774 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -261,6 +261,8 @@ async function createApp(opts = {}) {
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
// The mark's intro, as the files /brand offers to download.
app.use('/brand/motion', express.static(path.join(PUBLIC_DIR, 'brand', 'motion'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
++ // The release screenshots (scripts/capture-release.js) and the small player sample the player page shows.
++ app.use('/changes', express.static(path.join(PUBLIC_DIR, 'changes'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
// ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
// GET/PUT /api/v1/design/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
ddiff --git a/server/changes.js b/server/changes.js
nnew file mode 100644
iindex 0000000..99d8b06
---- /dev/null
++++ b/server/changes.js
@@@ -0,0 +1,347 @@
++'use strict';
++
++/**
++ * OpenVibe.Shared's changelog, read from the package this site pins (never a request to GitHub at page time), and the
++ * component pages and rules each release names. One module so /changes, its JSON and scripts/capture-release.js agree
++ * about what a release touches.
++ *
++ * releases() every release in the pinned CHANGELOG, newest first
++ * releaseOf(version) one release, or null
++ * pinned() { version, changelog }
++ * screenshots(version) the webp plates under public/changes/<version>/, grouped by component
++ * markdown(text) the small, escaped markdown the CHANGELOG uses (paragraphs, lists, bold, code, links)
++ *
++ * The release entries are rendered as written. The only additions are links: the component page a release touches and
++ * the rule numbers in the register it carries out. Those links are curated for the newest releases (RELEASES) and
++ * found by keyword for the older ones; a page link is never invented for a component that has no page here.
++ */
++const fs = require('fs');
++const path = require('path');
++const { PUBLIC_DIR } = require('./assets');
++const { RULES } = require('./rules/register');
++const tokens = require('openvibe-shared/tokens');
++const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
++
++const SHARED_DIR = path.dirname(require.resolve('openvibe-shared/package.json'));
++const SHARED_VERSION = require('openvibe-shared/package.json').version;
++const CHANGELOG = path.join(SHARED_DIR, 'CHANGELOG.md');
++const CHANGES_DIR = path.join(PUBLIC_DIR, 'changes');
++
++// A page this site already renders, or a note page added with the component. The href is what a release links to.
++const PAGES = Object.freeze({
++ brand: { title: 'The bar’s brand', href: '/components/brand' },
++ navbar: { title: 'The navbar', href: '/components/navbar' },
++ menu: { title: 'The menu', href: '/components/menu' },
++ 'you-home': { title: 'You home', href: '/components/you-home' },
++ 'search-pill': { title: 'The search pill', href: '/components/search-pill' },
++ dock: { title: 'The Dock', href: '/components/dock' },
++ 'quest-card': { title: 'The quest card', href: '/components/quest-card' },
++ avatar: { title: 'The avatar', href: '/components/avatar' },
++ player: { title: 'The player', href: '/components/player' },
++ footer: { title: 'The footer', href: '/components/footer' },
++ 'soft-pages': { title: 'Soft pages', href: '/components/soft-pages' },
++ 'icon-ink': { title: 'Icon ink', href: '/components/icon-ink' },
++ themes: { title: 'Themes', href: '/themes' },
++ motion: { title: 'Motion', href: '/motion' },
++ icons: { title: 'Icons', href: '/icons' },
++ kit: { title: 'The kit', href: '/kit' },
++ forms: { title: 'Forms', href: '/forms' },
++ loading: { title: 'Loading', href: '/loading' },
++ rules: { title: 'Rules', href: '/rules' },
++ system: { title: 'The system map', href: '/system' },
++ brandpage: { title: 'The mark', href: '/brand' },
++ tokens: { title: 'Tokens', href: '/tokens' },
++});
++
++// What the newest releases touch, read from their entries (the release headings are the source; this is only links).
++const RELEASES = Object.freeze({
++ '4.200.0': {
++ components: ['brand', 'menu', 'search-pill', 'dock'],
++ rules: ['button-becomes-its-tab', 'places-say-what-they-hold', 'actions-as-icons-in-the-pill', 'every-site-from-the-name', 'menu-opens-here'],
++ },
++ '4.199.0': { components: ['avatar', 'soft-pages'], rules: ['content-hashed-assets'] },
++ '4.198.0': { components: ['menu', 'dock'], rules: ['opens-it-closes-it', 'places-say-what-they-hold'] },
++ '4.197.0': { components: ['footer'], rules: ['say-it-once'] },
++ '4.196.0': { components: ['dock', 'kit'], rules: ['state-in-words', 'one-question', 'calm-reduces-off-removes'] },
++ '4.195.0': { components: ['menu', 'you-home'], rules: ['opens-it-closes-it', 'account-in-the-menu', 'menu-opens-here'] },
++ '4.194.0': { components: ['menu', 'search-pill', 'avatar'], rules: ['places-say-what-they-hold', 'one-pill-for-balances', 'account-in-the-menu'] },
++ '4.193.0': { components: ['menu', 'navbar', 'avatar'], rules: ['menu-button-at-the-right', 'one-menu-button', 'account-in-the-menu', 'calm-reduces-off-removes'] },
++ '4.192.0': { components: ['player'], rules: ['the-video-comes-along', 'no-spinners'] },
++ '4.191.0': { components: ['navbar', 'footer'], rules: ['one-bar'] },
++ '4.190.0': { components: ['brand', 'menu', 'footer', 'icon-ink'], rules: ['the-name-opens-its-pages', 'every-site-from-the-name', 'one-list-of-sites', 'a-change-has-a-mark'] },
++ '4.189.0': { components: ['menu', 'dock'], rules: ['one-pill-for-balances', 'menu-opens-here', 'menu-and-dock-meet'] },
++ '4.188.0': { components: ['menu', 'you-home', 'avatar'], rules: ['account-in-the-menu', 'only-the-pair'] },
++ '4.187.0': { components: ['menu', 'navbar', 'brand'], rules: ['one-menu-button', 'the-pair', 'two-badges-two-meanings'] },
++ '4.186.0': { components: ['motion', 'brandpage', 'dock'], rules: ['no-spinners', 'opens-it-closes-it'] },
++ '4.185.0': { components: ['you-home', 'dock'], rules: ['opens-it-closes-it', 'menu-and-dock-meet'] },
++ '4.184.0': { components: ['navbar'], rules: ['one-bar'] },
++ '4.183.0': { components: ['motion', 'icons'], rules: ['calm-reduces-off-removes', 'effects-by-device-not-screen', 'two-icon-sets'] },
++ '4.181.0': { components: ['dock'], rules: ['first-paint-alone'] },
++ '4.180.0': { components: ['motion'], rules: ['calm-reduces-off-removes'] },
++});
++
++// Older releases: find the pages a reader would want from the words the entry already uses. First match wins order.
++const KEYWORDS = [
++ ['quest-card', /\bquest card|\bquests?\b/i],
++ ['player', /\b(player|mini\.js|the video|video player)\b/i],
++ ['avatar', /\bavatar|\bthe picture\b/i],
++ ['brand', /\bbrand\b|\blogo\b|\bthe mark and the name\b/i],
++ ['search-pill', /\bsearch pill|\bsearch box|\bsearch field\b/i],
++ ['menu', /\bthe menu\b|\bmenu\b/i],
++ ['dock', /\bthe dock\b|\bdock\b/i],
++ ['you-home', /\bYou tab\b|\bYou home\b/i],
++ ['footer', /\bfooter\b/i],
++ ['navbar', /\bnavbar\b|\bthe bar\b/i],
++ ['soft-pages', /\bprefetch\b|\bview transition|\bsoft\.js\b|\bpage move\b/i],
++ ['icon-ink', /\bicon ink\b|\bapp icon\b|\bfavicon\b/i],
++ ['icons', /\bicons?\b|\bglyphs?\b/i],
++ ['motion', /\bmotion\b|\bcalm\b|\breduced motion\b/i],
++ ['themes', /\btheme\b|\blook\b/i],
++ ['kit', /\bkit\b|\bcomposer\b|\bsheet\b/i],
++ ['forms', /\bform controls?\b|\bfields?\b/i],
++ ['loading', /\bskeleton\b|\bspinner\b|\bloading\b/i],
++ ['rules', /\brules?\b/i],
++];
++
++const RULE_KEYWORDS = [
++ ['no-spinners', /\bspinner|\bspun\b/i],
++ ['the-video-comes-along', /\bvideo comes along|\bsmall player\b|\bmini player\b/i],
++ ['player-row-never-runs-off', /\bplayer'?s? controls?\b|\bone row\b/i],
++ ['places-say-what-they-hold', /\bplaces\b|\bVibe\b|\bChat\b/i],
++ ['button-becomes-its-tab', /\bflies? to its tab\b|\bbecomes its tab\b/i],
++ ['actions-as-icons-in-the-pill', /\bicon buttons?\b|\bsearch pill\b/i],
++ ['every-site-from-the-name', /\ball OpenVibe sites\b|\bevery site\b/i],
++ ['the-name-opens-its-pages', /\bsite'?s name\b|\bthe name\b/i],
++ ['menu-opens-here', /\bmenu opens\b|\bthis site'?s tab\b/i],
++ ['menu-and-dock-meet', /\bmenu and the Dock\b|\bDock'?s head\b/i],
++ ['one-menu-button', /\bone menu\b|\bmenu button\b/i],
++ ['account-in-the-menu', /\baccount\b|\bYou tab\b|\bdropdown\b/i],
++ ['one-pill-for-balances', /\bbalances?\b|\bwallet\b|\bpill\b/i],
++ ['calm-reduces-off-removes', /\bcalm\b|\breduced motion\b|\bmotion off\b/i],
++ ['effects-by-device-not-screen', /\bphone.*motion\b|\blite\b/i],
++ ['a-change-has-a-mark', /\bNew, Improved or Fixed\b|\bBetter\b/i],
++ ['two-icon-sets', /\btwo sets\b|\bglyph names?\b/i],
++ ['only-the-pair', /\bthe Pair\b|\bold logo\b|\bmark\b/i],
++ ['one-bar', /\bone bar\b|\bnavbar\b|\bempty band\b/i],
++ ['content-hashed-assets', /\bcontent hash|\?v=|\bimmutable\b|\bminified\b/i],
++ ['first-paint-alone', /\bfirst paint\b|\brelay\b/i],
++ ['one-question', /\bone concrete question\b/i],
++ ['state-in-words', /\bstatus\b|\bstate\b/i],
++ ['say-it-once', /\bone tooltip\b|\bsays it once\b|\bone pill\b/i],
++ ['opens-it-closes-it', /\bclose button\b|\bfolds? back\b|\bopens out\b/i],
++ ['one-list-of-sites', /\bone list of sites\b|\beight groups\b/i],
++ ['two-badges-two-meanings', /\bcount\b.*\bdot\b|\bbadge\b/i],
++ ['the-pair', /\bthe Pair\b|\bmark\b/i],
++];
++
++const RULE_BY_ID = new Map(RULES.map((r) => [r.id, r]));
++// Rule numbers: the register's order is the number a person can cite ("rule 7"). Stable because rules are appended.
++const numberOf = (id) => (RULE_BY_ID.get(id) || {}).n || null;
++
++let cached = null;
++
++/** The package this site pins, and the changelog file read from it. */
++function pinned() {
++ return { version: SHARED_VERSION, changelog: 'CHANGELOG.md' };
++}
++
++/** Escape text for HTML. */
++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** The changelog's small markdown: paragraphs, lists (nested one level), bold, code, links. Every value is escaped. */
++function markdown(text) {
++ const safeLink = (href) => (/^https?:\/\//.test(href) || /^\/(?!\/)/.test(href) ? href : null);
++ const inline = (s) => esc(s)
++ .replace(/`([^`]+)`/g, '<code>$1</code>')
++ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
++ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, href) => {
++ const url = safeLink(href);
++ return url ? `<a href="${esc(url)}"${/^https?:/.test(url) ? ' rel="noopener"' : ''}>${label}</a>` : label;
++ })
++ .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, (m, pre, url) => `${pre}<a href="${esc(url)}" rel="noopener">${esc(url)}</a>`);
++ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
++ const out = [];
++ let para = [];
++ let list = null; // { level, items: [] }
++ const flushPara = () => {
++ if (!para.length) return;
++ out.push(`<p>${inline(para.join(' ').trim())}</p>`);
++ para = [];
++ };
++ const flushList = () => {
++ if (!list) return;
++ // One level of nesting: the nested items sit in their own <ul> inside the parent <li>.
++ const html = list.items.map((item) => (item.children.length
++ ? `<li>${inline(item.text)}${item.children.length ? `<ul>${item.children.map((c) => `<li>${inline(c)}</li>`).join('')}</ul>` : ''}</li>`
++ : `<li>${inline(item.text)}</li>`)).join('');
++ out.push(`<ul>${html}</ul>`);
++ list = null;
++ };
++ for (const raw of lines) {
++ const line = raw.replace(/\s+$/, '');
++ if (!line.trim()) { flushPara(); flushList(); continue; }
++ const heading = line.match(/^(#{3,4})\s+(.*)$/);
++ if (heading) {
++ flushPara(); flushList();
++ const level = heading[1].length;
++ out.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
++ continue;
++ }
++ const item = line.match(/^(\s*)-\s+(.*)$/);
++ if (item) {
++ flushPara();
++ const indent = item[1].length;
++ if (!list) list = { level: indent, items: [] };
++ else if (indent > list.level && list.items.length) {
++ const parent = list.items[list.items.length - 1];
++ parent.children.push(item[2].trim());
++ continue;
++ }
++ list.items.push({ text: item[2].trim(), children: [] });
++ continue;
++ }
++ if (list) {
++ // A wrapped list item: the changelog indents its continuation by two spaces.
++ const last = list.items[list.items.length - 1];
++ if (raw.startsWith(' ') && last) {
++ if (last.children.length) last.children[last.children.length - 1] += ` ${line.trim()}`;
++ else last.text += ` ${line.trim()}`;
++ continue;
++ }
++ flushList();
++ }
++ para.push(line.trim());
++ }
++ flushPara();
++ flushList();
++ return out.join('\n');
++}
++
++/** The first bold sentence of an entry, or its first sentence: the release's own title. */
++function titleOf(body) {
++ const bold = body.match(/\*\*([\s\S]+?)\*\*/);
++ if (bold) return bold[1].replace(/\s+/g, ' ').trim();
++ const first = body.replace(/^[-*\s]+/, '').split(/(?<=[.!?])\s/)[0];
++ return first ? first.trim() : '';
++}
++
++function parse(md) {
++ const lines = String(md || '').replace(/\r\n/g, '\n').split('\n');
++ const releases = [];
++ let current = null;
++ const heading = /^##\s+(\d+\.\d+\.\d+)\s*(?:—|-)\s*(\d{4}-\d{2}-\d{2})\s*$/;
++ for (const line of lines) {
++ const m = line.match(heading);
++ if (m) {
++ if (current) releases.push(current);
++ current = { version: m[1], date: m[2], body: [] };
++ continue;
++ }
++ if (current) current.body.push(line);
++ }
++ if (current) releases.push(current);
++ // A version written twice in the changelog is one release: keep its first place and all of its words.
++ const byVersion = new Map();
++ const unique = [];
++ for (const r of releases) {
++ if (byVersion.has(r.version)) byVersion.get(r.version).body.push('', ...r.body);
++ else { byVersion.set(r.version, r); unique.push(r); }
++ }
++ for (const r of unique) {
++ r.body = r.body.join('\n').trim();
++ r.title = titleOf(r.body);
++ }
++ return unique;
++}
++
++function read() {
++ if (cached) return cached;
++ let md = '';
++ try { md = fs.readFileSync(CHANGELOG, 'utf8'); } catch { md = '# Changelog\n'; }
++ cached = parse(md);
++ return cached;
++}
++
++function releases() {
++ return read();
++}
++
++function releaseOf(version) {
++ const key = String(version || '').replace(/^v/, '');
++ return read().find((r) => r.version === key) || null;
++}
++
++function componentsOf(release) {
++ const meta = RELEASES[release.version];
++ if (meta) return meta.components.filter((k) => PAGES[k]).map((k) => ({ key: k, ...PAGES[k] }));
++ const text = `${release.title} ${release.body}`;
++ const found = [];
++ for (const [key, re] of KEYWORDS) {
++ if (found.length >= 4) break;
++ if (re.test(text) && PAGES[key] && !found.some((f) => f.key === key)) found.push({ key, ...PAGES[key] });
++ }
++ return found;
++}
++
++function rulesOf(release) {
++ const meta = RELEASES[release.version];
++ const ids = meta ? meta.rules : [];
++ if (!meta) {
++ const text = `${release.title} ${release.body}`;
++ for (const [id, re] of RULE_KEYWORDS) {
++ if (ids.length >= 3) break;
++ if (re.test(text) && RULE_BY_ID.has(id) && !ids.includes(id)) ids.push(id);
++ }
++ }
++ return ids.map((id) => ({ id, n: numberOf(id), rule: RULE_BY_ID.get(id).rule, href: `/rules#${id}` }));
++}
++
++/** The screenshots a release has in the repo: public/changes/<version>/<component>-<width>-<scheme>.webp. */
++function screenshots(version) {
++ const key = String(version || '').replace(/^v/, '');
++ const dir = path.join(CHANGES_DIR, key);
++ let files = [];
++ try { files = fs.readdirSync(dir).filter((f) => /^[a-z0-9-]+-(390|1280)-(dark|light)\.webp$/.test(f)); } catch { return []; }
++ const groups = new Map();
++ for (const file of files.sort()) {
++ const m = file.match(/^([a-z0-9-]+)-(390|1280)-(dark|light)\.webp$/);
++ if (!m) continue;
++ const [, component, width, scheme] = m;
++ if (!groups.has(component)) groups.set(component, { component, title: (PAGES[component] || {}).title || component, files: [] });
++ groups.get(component).files.push({ width: Number(width), scheme, href: `/changes/${key}/${file}`, file });
++ }
++ const order = ['dark', 'light'];
++ for (const g of groups.values()) g.files.sort((a, b) => a.width - b.width || order.indexOf(a.scheme) - order.indexOf(b.scheme));
++ return [...groups.values()].sort((a, b) => Object.keys(PAGES).indexOf(a.component) - Object.keys(PAGES).indexOf(b.component));
++}
++
++/** The whole archive as data, for /api/v1/changes and the tests. */
++function archive() {
++ const all = releases();
++ return {
++ schema: 'openvibe.changes@1',
++ pinned: pinned(),
++ releases: all.map((r) => ({
++ version: r.version,
++ date: r.date,
++ title: r.title,
++ body: r.body,
++ components: componentsOf(r),
++ rules: rulesOf(r),
++ screenshots: screenshots(r.version),
++ })),
++ };
++}
++
++/** The tokens of a built-in theme as CSS, with !important: what the capture script injects for a light or dark plate. */
++function schemeCss(mode) {
++ const light = mode === 'light';
++ const theme = BUILTIN_THEMES.find((t) => t.slug === (light ? 'daylight' : 'vibe')) || BUILTIN_THEMES[0];
++ const doc = tokens.fromTheme(theme);
++ const css = tokens.compile(doc, 'css') || '';
++ return css
++ .replace(/(--[a-zA-Z0-9-]+:[^;]+);/g, '$1 !important;')
++ .replace(/color-scheme:\s*(light|dark);/g, 'color-scheme: $1 !important;');
++}
++
++module.exports = { releases, releaseOf, pinned, componentsOf, rulesOf, screenshots, archive, markdown, titleOf, schemeCss, PAGES, RELEASES, SHARED_VERSION };
ddiff --git a/server/components/registry.js b/server/components/registry.js
iindex fdfb20d..c385c8a 100644
---- a/server/components/registry.js
++++ b/server/components/registry.js
@@@ -18,8 +18,8 @@ const define = (name, title, summary, anatomy, props, states, tokens, rules, web
});
const COMPONENTS = Object.freeze([
-- define('navbar', 'Navbar', 'The site identity and navigation with lead and action slots.', [['lead', 'brand and page context'], ['links', 'primary destinations'], ['actions', 'account and page controls']], { lead: 'string', links: 'link[]', actions: 'action[]', active: 'string' }, ['default', 'menu-open', 'scrolled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Tab through lead, destinations and actions; Escape closes the menu' } }, ['navbar', 'OpenVibeNavbar with lead and action slots'], ['SurfaceContainer', 'Use a TopAppBar with navigationIcon and actions slots; place overflow destinations in a modal menu.'], 'Use a toolbar with leading and trailing ToolbarItems, and a Menu for overflow.', { lead: 'OpenVibe', active: 'Design' }),
-- define('menu', 'Menu', 'One navigation sheet with its You home and app destinations.', [['trigger', 'opens navigation'], ['you', 'account home'], ['apps', 'destination rows']], { open: 'boolean', person: 'person?', destinations: 'link[]' }, ['closed', 'open', 'signed-out'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Move focus into the menu; Escape returns it to the trigger' } }, ['navbar', 'OpenVibeNavbar menu and You home'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer with a You header and app rows.'], 'Use a sheet with a NavigationStack and You header.', { open: true }),
++ define('navbar', 'Navbar', 'The site identity and navigation: the brand as one button, the menu button at the right, the person last.', [['brand', 'the mark and the site name, one button'], ['links', 'primary destinations'], ['actions', 'the menu button, the person, page controls']], { lead: 'string', links: 'link[]', actions: 'action[]', active: 'string' }, ['default', 'menu-open', 'scrolled'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Tab through lead, destinations and actions; Escape closes the menu' } }, ['navbar', 'OpenVibeNavbar with lead and action slots'], ['SurfaceContainer', 'Use a TopAppBar with navigationIcon and actions slots; place overflow destinations in a modal menu.'], 'Use a toolbar with leading and trailing ToolbarItems, and a Menu for overflow.', { lead: 'OpenVibe', active: 'Design' }),
++ define('menu', 'Menu', 'One navigation sheet: Here, Feed, Vibe with Chat · Agents, Chat and You, with the pressed button flying to its tab.', [['trigger', 'the pressed button, which becomes its tab'], ['places', 'Here, Feed, Vibe, Chat, You'], ['search', 'the field and the place’s actions'], ['content', 'the chosen place']], { open: 'boolean', person: 'person?', places: 'link[]' }, ['closed', 'opening', 'open', 'signed-out'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Move focus into the menu; Escape returns it to the trigger' } }, ['navbar', 'OpenVibeNavbar menu and You home'], ['SurfaceContainerHigh', 'Use ModalNavigationDrawer with a You header and app rows.'], 'Use a sheet with a NavigationStack and You header.', { open: true }),
define('you-home', 'You home', 'The personal starting view inside the menu.', [['identity', 'avatar and name'], ['shortcuts', 'your places'], ['settings', 'display and account']], { person: 'person?', shortcuts: 'link[]' }, ['signed-in', 'signed-out', 'loading'], ['color.bg.raised', 'color.text.secondary', 'size.tap'], { keyboardAware: true }, ['navbar', 'Menu You panel'], ['SurfaceContainerHigh', 'Use a Column of ListItem shortcuts under the account identity.'], 'Use a List with account identity and destination sections.', { person: 'You' }),
define('dock', 'Dock', 'Persistent place for the current task, clear of the phone app bar and keyboard.', [['surface', 'bottom container'], ['content', 'current task'], ['action', 'send or confirm']], { content: 'string', action: 'action', busy: 'boolean' }, ['ready', 'busy', 'disabled'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Keep the action after the content in focus order' } }, ['kit', 'kit.dock({ action, name, placeholder, label })'], ['SurfaceContainer', 'Use Scaffold.bottomBar and imePadding; keep the primary action in a 48 dp control.'], 'Use safeAreaInset(edge: .bottom) with keyboard avoidance.', { content: 'Write a message', action: 'Send' }),
define('dock-tabs', 'Dock tabs', 'The phone destinations attached to the bottom app bar.', [['tab', 'icon and label'], ['selection', 'active destination']], { items: 'link[]', active: 'string' }, ['default', 'selected', 'pressed'], ['color.accent.strong', 'color.text.muted', 'size.tap'], { keyboardAware: true, accessibility: { name: 'Each tab announces its label and selected state' } }, ['appbar', 'OpenVibeAppbar from the site navigation'], ['SurfaceContainer', 'Reference: OvTabs in OpenVibe.Link\'s Android app (android/app/src/main/java/network/openvibe/kit/OvKit.kt). Use NavigationBar with NavigationBarItem and selected state.'], 'Use TabView with system tab items.', { active: 'Studio' }),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7de01ef..36cd2a6 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -33,6 +33,7 @@ const { ownOrigin, bearer, requireSameOrigin } = require('./same-origin');
const themeTokens = require('../tokens');
const components = require('openvibe-shared/components');
const legacyRegistry = require('../components/registry');
++const changesView = require('../views/changes');
/** A Network 4xx answer, with its own status and body. A 2xx answer is passed through the same way. */
function passThrough(res, r) {
@@@ -112,6 +113,13 @@ function createApi(ctx) {
});
// ── Components: the registry as JSON, and a kit document checked against it (public; read-only) ──
++ // Every release in the pinned openvibe-shared, with the pages and rules it touches (server/changes.js).
++ r.get('/v1/changes', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.set('Access-Control-Allow-Origin', '*');
++ return res.json(changesView.changesJson());
++ });
++
r.get('/v1/components', async (req, res) => {
const entries = await registry.list();
res.set('Cache-Control', 'public, max-age=3600');
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 92b61a3..756d83c 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -40,6 +40,7 @@ function publicPages(themes = []) {
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The component registry as one plain list: every Shared kit component as a design.component@1 entry with its props, states, example markup and Android and iOS notes.' },
++ { path: '/changes', changefreq: 'daily', priority: 0.8, title: 'Shared releases', text: 'Every openvibe-shared release in the pinned changelog, newest first, with the component pages and rules it touches and its screenshots.' },
{ path: '/components/registry', changefreq: 'weekly', priority: 0.8, title: 'Component registry', text: 'Every shared OpenVibe interface as one design.component@1 definition: navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion, for web, Android and iOS.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
@@@ -115,6 +116,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'Themes', url: abs('/themes'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Rules', url: abs('/rules'), note: 'every style, design and organization decision behind OpenVibe, with the day and the reason; read it before designing for OpenVibe. JSON: GET /api/v1/rules' },
++ { title: 'Shared releases', url: abs('/changes'), note: 'every openvibe-shared release in the pinned changelog, newest first, with the component pages and rule numbers it touches and the screenshots the repo holds. JSON: GET /api/v1/changes' },
{ title: 'The design system', url: abs('/system'), note: 'every reference page in four groups: foundations, components, guidance, platforms' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 08b41bb..4daf109 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -32,6 +32,7 @@ const themeTokens = require('../tokens');
const designView = require('../views/design');
const patternsView = require('../views/patterns');
const brandView = require('../views/brand');
++const changesView = require('../views/changes');
const motionView = require('../views/motion');
const { baseUrl: layoutBase } = require('../render/layout');
const { asyncRouter } = require('./router');
@@@ -241,6 +242,13 @@ function createPageRoutes(ctx) {
// The decision register and the system map.
const rulesView = require('../views/rules');
r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));
++ // Every openvibe-shared release, read from the pinned package (server/changes.js).
++ r.get('/changes', (req, res) => page(res, 200, changesView.changesPage(), req.viewer));
++ // The tokens of one built-in theme with !important: scripts/capture-release.js wears them for a light or dark plate.
++ r.get('/scheme.css', (req, res) => {
++ const css = require('../changes').schemeCss(req.query.scheme === 'light' ? 'light' : 'dark');
++ res.set('Cache-Control', 'public, max-age=3600').type('text/css').send(css);
++ });
r.get('/system', async (req, res) => {
const list = await safe(() => catalog.list());
const n = list && Array.isArray(list.themes) ? list.themes.length : null;
@@@ -295,6 +303,8 @@ function createPageRoutes(ctx) {
r.get('/components/:name', async (req, res) => {
const designed = require('../views/component-registry').componentPage(req.params.name);
if (designed) return page(res, 200, designed, req.viewer);
++ const note = require('../views/component-notes').notePage(req.params.name);
++ if (note) return page(res, 200, note, req.viewer);
try {
const entry = await registry.get(req.params.name);
return entry ? page(res, 200, componentsListView.componentEntryPage(entry), req.viewer)
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex 97a2c22..3d5f943 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -392,11 +392,14 @@ const RULES = Object.freeze([
'2026-10-25', 'A phone rule looked right in a preview and lost to a later file in production.'),
]);
++// The register's order is the rule's number: a person can cite "rule 7" and a link can carry it.
++const NUMBERED = Object.freeze(RULES.map((rule, i) => Object.freeze({ ...rule, n: i + 1 })));
++
function byArea() {
-- return AREAS.map((a) => ({ ...a, rules: RULES.filter((r) => r.area === a.id) })).filter((a) => a.rules.length);
++ return AREAS.map((a) => ({ ...a, rules: NUMBERED.filter((r) => r.area === a.id) })).filter((a) => a.rules.length);
}
function latest() {
-- return RULES.map((r) => r.decided).sort().pop() || null;
++ return NUMBERED.map((r) => r.decided).sort().pop() || null;
}
--module.exports = { AREAS, RULES, byArea, latest };
++module.exports = { AREAS, RULES: NUMBERED, byArea, latest };
ddiff --git a/server/views/changes.js b/server/views/changes.js
nnew file mode 100644
iindex 0000000..7d43821
---- /dev/null
++++ b/server/views/changes.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * /changes: every openvibe-shared release this site pins, newest first, its changelog entry as written, the component
++ * pages it touches, the rule numbers it carries out, and the screenshots the repo holds for it. The page never calls
++ * the network: the release text comes from node_modules/openvibe-shared/CHANGELOG.md (server/changes.js).
++ *
++ * changesPage() the page
++ * changesJson() the same archive as data (/api/v1/changes)
++ */
++const kit = require('openvibe-shared/kit');
++const { layout, esc } = require('../render/layout');
++const { releases, markdown, componentsOf, rulesOf, screenshots, pinned } = require('../changes');
++
++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++const day = (iso) => {
++ const [y, m, d] = String(iso || '').split('-').map(Number);
++ return y ? `${d} ${MONTHS[m - 1]} ${y}` : '';
++};
++const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
++
++function linkChips(links, cls = '') {
++ return links.map((l) => `<a class="chg-chip${cls}" href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
++}
++
++function shotFigure(group, version) {
++ const first = group.files.find((f) => f.width === 390 && f.scheme === 'dark') || group.files[0];
++ return `<figure class="chg-shot">
++ <a href="${esc(first.href)}"><img src="${esc(first.href)}" alt="${esc(`${group.title} at ${first.width} px, ${first.scheme}`)}" width="390" height="auto" loading="lazy" decoding="async"></a>
++ <figcaption><b>${esc(group.title)}</b><span>${group.files.map((f) => `<a href="${esc(f.href)}">${f.width} · ${f.scheme}</a>`).join('')}</span></figcaption>
++</figure>`;
++}
++
++function releaseCard(r) {
++ const components = componentsOf(r);
++ const rules = rulesOf(r);
++ const shots = screenshots(r.version);
++ return `<article class="chg-release" id="v${esc(r.version)}" data-release="${esc(r.version)}">
++ <header class="chg-head">
++ <div><h2><a href="#v${esc(r.version)}">${esc(r.version)}</a></h2><time datetime="${esc(r.date)}">${esc(day(r.date))}</time></div>
++ </header>
++ <div class="chg-entry">${markdown(r.body)}</div>
++ ${components.length ? `<p class="chg-refs"><span class="chg-label">Pages</span>${linkChips(components.map((c) => ({ href: c.href, label: c.title })))}</p>` : ''}
++ ${rules.length ? `<p class="chg-refs"><span class="chg-label">Rules</span>${rules.map((x) => `<a class="chg-chip chg-chip--rule" href="${esc(x.href)}" title="${esc(x.rule)}"><b>${esc(String(x.n))}</b> ${esc(x.rule)}</a>`).join('')}</p>` : ''}
++ ${shots.length ? `<div class="chg-shots" data-shots="${shots.length}"><p class="chg-label">Screenshots in the repo</p><div class="chg-shot-row">${shots.map((g) => shotFigure(g, r.version)).join('')}</div></div>` : ''}
++</article>`;
++}
++
++function changesPage() {
++ const all = releases();
++ const pin = pinned();
++ const counted = all.filter((r) => screenshots(r.version).length).length;
++ const hero = `<header class="ds-page-h chg-hero"><p class="ds-kicker">OpenVibe.Shared</p><h1>Every shared release, and where it shows</h1>
++<p class="ds-lede">Each release of the shared package every OpenVibe site loads, newest first, in the words its own changelog uses. The text is read from <code>openvibe-shared@${esc(pin.version)}</code>, the version this site pins; no request is made to GitHub when a person opens this page. Each release links to the component pages it touches, numbers the <a href="/rules">rules</a> it carries out, and shows the screenshots the repo holds for it.</p>
++${kit.stats([[all.length, 'releases'], [counted, 'with screenshots'], [pin.version, 'pinned']], { label: 'The archive in numbers' })}
++<p class="chg-links">${kit.button({ href: '/components/registry', label: 'The components', kind: 'quiet', icon: 'puzzle' })}${kit.button({ href: '/rules', label: 'The rules', kind: 'quiet', icon: 'rules' })}${kit.button({ href: '/api/v1/changes', label: 'As JSON', kind: 'quiet', icon: 'code' })}</p>
++<p class="ds-fine">Older entries name fewer pages: the changelog is old, the component pages are new. The link is left out rather than guessed.</p></header>`;
++ const latest = all.slice(0, 12).map((r) => `<a href="#v${esc(r.version)}"><b>${esc(r.version)}</b><span>${esc(r.title)}</span></a>`).join('');
++ const index = all.length > 12 ? `<nav class="chg-index" aria-label="Newest releases"><p class="chg-label">Newest</p><div class="chg-index-row">${latest}</div><p class="ds-fine">${all.length} releases in all; every one follows, newest first.</p></nav>` : '';
++ const list = all.map(releaseCard).join('');
++ return layout({
++ title: `Changes: ${all.length} OpenVibe.Shared releases`,
++ description: `Every OpenVibe.Shared release in the pinned changelog, newest first: what changed, the component pages it touches, the rules it carries out, and its screenshots.`,
++ path: '/changes',
++ active: '/components',
++ styles: ['css/changes.css'],
++ body: `<article class="chg">${hero}${index}<div class="chg-list">${list}</div>
++<footer class="chg-foot"><p>Read from <code>openvibe-shared@${esc(pin.version)}</code>. A release is listed in the order the package writes it; the links are the only additions.</p>
++<p>${kit.button({ href: '/components/registry', label: 'Every component', kind: 'quiet' })}${kit.button({ href: '/rules', label: 'How OpenVibe decides', kind: 'quiet' })}</p></footer></article>`,
++ });
++}
++
++function changesJson() {
++ const all = releases();
++ return {
++ schema: 'openvibe.changes@1',
++ pinned: pinned(),
++ releases: all.map((r) => ({
++ version: r.version,
++ date: r.date,
++ title: r.title,
++ pages: componentsOf(r).map((c) => ({ key: c.key, title: c.title, href: c.href })),
++ rules: rulesOf(r),
++ screenshots: screenshots(r.version),
++ })),
++ };
++}
++
++module.exports = { changesPage, changesJson };
ddiff --git a/server/views/component-notes.js b/server/views/component-notes.js
nnew file mode 100644
iindex 0000000..b5fc431
---- /dev/null
++++ b/server/views/component-notes.js
@@@ -0,0 +1,316 @@
++'use strict';
++/**
++ * The component pages for what has shipped since the first registry: the bar's brand, the search pill, the quest
++ * card, the avatar, the player, soft pages, icon ink and the footer. Each page has one specimen, built from the
++ * openvibe-shared this site pins wherever that package already holds the part, and the release, the changelog words
++ * and the numbered rules behind it. A part that is still in review says so; nothing is claimed as shipped before it is.
++ *
++ * notePage(name) one page, or null
++ * NOTES the names and their titles (also used by /components/registry)
++ */
++const kit = require('openvibe-shared/kit');
++const mark = require('openvibe-shared/mark');
++const appIcon = require('openvibe-shared/app-icon');
++const { layout, esc } = require('../render/layout');
++const { RULES } = require('../rules/register');
++
++const RULE = new Map(RULES.map((r) => [r.id, r]));
++const chip = (href, label, cls = '') => `<a class="cn-chip${cls ? ` ${cls}` : ''}" href="${esc(href)}">${label}</a>`;
++const ruleChips = (ids) => ids.filter((id) => RULE.has(id)).map((id) => {
++ const r = RULE.get(id);
++ return `<a class="cn-chip cn-chip--rule" href="/rules#${esc(id)}" title="${esc(r.rule)}"><b>${esc(String(r.n))}</b> ${esc(r.rule)}</a>`;
++}).join('');
++
++const brandMark = mark.svg({ form: 'pair', tone: 'brand', wide: true, size: 34 });
++const appTile = appIcon.favicon({ site: 'network' });
++
++// ── The specimens. Every value is written here (escaped by the builder that takes it), never read from a person. ──
++const SPECIMENS = {
++ brand: () => `<div class="spec spec--brand">
++ <div class="spec-bar">
++ <button type="button" class="ovnav-brand" aria-haspopup="menu" aria-expanded="false" aria-label="OpenVibe.Design: its pages and every OpenVibe site">
++ <span class="flame">${brandMark}</span>
++ <span class="name"><span class="b-core">OpenVibe</span><span class="b-dot">.</span><span class="b-tld">Design</span></span>
++ </button>
++ <span class="spec-orb" aria-hidden="true">${[0, 1, 2].map(() => '<i></i>').join('')}</span>
++ </div>
++ <div class="spec-drop" role="group" aria-label="What the button opens">
++ <p class="spec-drop-h">Pages on OpenVibe.Design</p>
++ <a href="/see"><span>${kit.icon('home')}</span>See</a>
++ <a href="/make"><span>${kit.icon('spark')}</span>Make</a>
++ <a href="/rules" aria-current="page"><span>${kit.icon('check')}</span>Rules</a>
++ <a href="/use"><span>${kit.icon('code')}</span>Use</a>
++ <a class="spec-drop-all" href="/components/menu"><span>${kit.icon('grid')}</span>All OpenVibe sites</a>
++ </div>
++ <p class="spec-note">The mark and the name are one button, and the chevron says it opens. Its list starts with this site's pages; <b>All OpenVibe sites</b> at the end opens the menu past them to every site. The menu button keeps its own place at the right of the bar.</p>
++</div>`,
++
++ menu: () => `<div class="spec spec--menu">
++ <div class="spec-sheet" role="group" aria-label="The menu's places">
++ <div class="spec-places">
++ <button type="button" class="is-on" aria-current="page">${kit.icon('home')}<span>Here</span></button>
++ <button type="button">${kit.icon('list')}<span>Feed</span></button>
++ <button type="button">${kit.icon('spark')}<span>Vibe</span><small>Chat · Agents</small></button>
++ <button type="button">${kit.icon('chat')}<span>Chat</span></button>
++ <button type="button">${kit.icon('user')}<span>You</span><i class="spec-badge">3</i></button>
++ </div>
++ <form class="spec-search" role="search" aria-label="The search pill">
++ ${kit.icon('search')}
++ <input type="search" placeholder="Search OpenVibe" aria-label="Search OpenVibe" value="">
++ <span class="spec-actions">
++ <button type="button" aria-label="New chat" title="New chat">${kit.icon('plus')}</button>
++ <button type="button" aria-label="Open in OpenVibe.Chat" title="Open in Chat">${kit.icon('open')}</button>
++ <button type="button" class="spec-close" aria-label="Close the menu" title="Close">${kit.icon('close')}</button>
++ </span>
++ </form>
++ <p class="spec-note">One row of five places: Here (this site), Feed, Vibe with its <b>Chat · Agents</b> switch, Chat, and You with the red count of what is new. Vibe's <b>+</b> starts a chat and <b>↗</b> opens it in OpenVibe.Chat; the icons sit in the search pill beside the close button, so they cost no row. On a phone the menu is one sheet and Chat and Vibe open inside it; on a computer they open the Dock.</p>
++ </div>
++</div>`,
++
++ 'search-pill': () => `<div class="spec spec--search">
++ <form class="spec-search spec-search--big" role="search" aria-label="The search pill">
++ ${kit.icon('search')}
++ <input type="search" placeholder="Search OpenVibe" aria-label="Search OpenVibe" value="night owls">
++ <span class="spec-actions">
++ <button type="button" aria-label="New chat" title="New chat">${kit.icon('plus')}</button>
++ <button type="button" aria-label="Open in OpenVibe.Chat" title="Open in Chat">${kit.icon('open')}</button>
++ <button type="button" class="spec-close" aria-label="Close the menu" title="Close">${kit.icon('close')}</button>
++ </span>
++ </form>
++ <div class="spec-split">
++ <div><b>The field</b><p>The menu's search, and the place's own field when a place has one. Typing filters the chosen place in place; Enter opens the first result.</p></div>
++ <div><b>The actions</b><p><b>+</b> starts a new chat; <b>↗</b> opens the same place in OpenVibe.Chat; <b>×</b> closes the menu and steps back. Each is an icon button with a spoken label and a tooltip, and each has a 44 px target on a phone.</p></div>
++ </div>
++ <p class="spec-note">Inside the menu the place's actions sit in the pill; the floating Dock keeps them in a slim row of their own. The pill itself is the kit's search: one field, one button, no invented control.</p>
++</div>`,
++
++ 'quest-card': () => `<div class="spec spec--quest">
++ <div class="ovq-quest open" data-progress="1">
++ <div class="ovq-head" role="button" tabindex="0" aria-expanded="true">
++ <span class="ovq-icon" aria-hidden="true">${kit.icon('trophy')}</span>
++ <span class="ovq-titles"><span class="ovq-kicker">TODAY'S QUEST · 1 OF 3</span><span class="ovq-title">Say hello where you watch</span><span class="ovq-bar" aria-hidden="true"><i></i><i></i><i></i></span></span>
++ <span class="ovq-chip">${kit.icon('fire')} 4</span>
++ <span class="ovq-chev" aria-hidden="true">${kit.icon('chevron')}</span>
++ </div>
++ <div class="ovq-body">
++ <p class="ovq-story">One small thing today, on the sites you already use. The badge is yours when the three steps are done.</p>
++ <ol class="ovq-steps">
++ <li class="is-done"><a href="/components/quest-card"><span class="ovq-site-icon">${kit.icon('chat')}</span><span class="ovq-step"><span>Say hello in a chat</span><small>OpenVibe.Chat</small></span><span class="ovq-check" aria-label="Done">✓</span></a></li>
++ <li class="is-next"><a href="/components/quest-card"><span class="ovq-site-icon">${kit.icon('user')}</span><span class="ovq-step"><span>Follow one person</span><small>Next · OpenVibe.Network</small></span><span class="ovq-go">›</span></a></li>
++ <li><a href="/components/quest-card"><span class="ovq-site-icon">${kit.icon('feed')}</span><span class="ovq-step"><span>Start a thread</span><small>OpenVibe.Community</small></span><span class="ovq-go">›</span></a></li>
++ </ol>
++ <a class="ovq-more" href="/components/quest-card"><span class="ovq-more-text"><b>More on OpenVibe.Quest</b><span>4 badges · 1 quest going · 9 more to try</span></span><span class="ovq-arrow">→</span></a>
++ </div>
++ </div>
++ <p class="spec-note">The daily quest card any site can show: its badge mark, the day's quest, the three steps with the next one lit, and one door to OpenVibe.Quest. Its marks are its own SVG, not Font Awesome, and the door's arrow takes the accent.</p>
++</div>`,
++
++ avatar: () => `<div class="spec spec--avatar">
++ <div class="spec-avatars">
++ <figure><span class="spec-av spec-av--face">${kit.mark({ name: 'Ana', src: '/changes/avatar-sample.svg', size: 48 })}</span><figcaption>A picture: the account's own</figcaption></figure>
++ <figure><span class="spec-av spec-av--no">${kit.mark({ name: 'A', size: 48 })}</span><figcaption>No picture: the initial</figcaption></figure>
++ <figure><span class="spec-av spec-av--broken"><span class="spec-av-shot" aria-hidden="true"></span><span class="spec-av-init">A</span></span><figcaption>A picture that cannot show: the initial, not a broken image</figcaption></figure>
++ </div>
++ <p class="spec-note">One <code>capture</code> listener and one sweep replace every <code>&lt;img data-ov-initial&gt;</code> that fails with its initial, in the footer's faces and the account picture. It is CSP-safe: no inline handler, no request to another origin. A picture that loads keeps its reserved width and height, so nothing shifts while it arrives.</p>
++</div>`,
++
++ player: () => `<div class="spec spec--player">
++ <div class="spec-player">
++ <video data-ov-player controls playsinline preload="metadata" width="640" height="360" src="/changes/specimen-player.mp4" aria-label="A sample OpenVibe player"></video>
++ </div>
++ <div class="spec-split">
++ <div><b>On a phone</b><p>The controls are one row: play, mute, the volume slider, the time, captions and fullscreen. Speed and picture-in-picture step out; under 380 px the mute button stands alone; under 360 px the time shows the position alone. The row never runs off the screen.</p></div>
++ <div><b>Everywhere</b><p>The same player draws every <code>&lt;video data-ov-player&gt;</code>: the network's controls, the picture's own colour while it loads, and the small player that keeps a video going while you browse (<code>mini.js</code>).</p></div>
++ </div>
++ <p class="spec-note">The phone row here is the change in review, applied to the pinned player so you can see it: speed and picture-in-picture step out and the slider is 64 px, which is what keeps fullscreen on the screen. The 4.200 player still shows both at 390 px and cuts it.</p>
++</div>`,
++
++ 'soft-pages': () => `<div class="spec spec--soft">
++ <ol class="spec-steps">
++ <li><span class="spec-step-n">1</span><div><b>Reach for a link</b><p>A same-site link is fetched on pointer down, or after 80 ms of hover on a computer. Never on Save-Data or a 2G/3G connection; at most four in flight; never sign-in, auth, API, a post, a download, a query or an in-page anchor. A page can opt a link out with <code>data-ov-no-prefetch</code>.</p></div></li>
++ <li><span class="spec-step-n">2</span><div><b>Press</b><p>The mark's O starts its wait (<code>OVMark.navStart('page')</code>) at pointer down, and the arriving page finishes it at first paint. The bar's ring is the wait, not a spinner.</p></div></li>
++ <li><span class="spec-step-n">3</span><div><b>Arrive</b><p>Where the browser has cross-document view transitions, the shared bar is named once and held still while the rest of the page cross-fades: 180 ms, Calm 120 ms, Off none. A page with its own <code>script[type="speculationrules"]</code> keeps it.</p></div></li>
++ </ol>
++ <p class="spec-note">One line opts a site in, in <code>&lt;head&gt;</code> after <code>theme-loader.js</code> and not deferred: <code>&lt;script src="/shared/soft.js"&gt;&lt;/script&gt;</code>. A site that does not opt in is unchanged. <code>window.OpenVibeSoft = { on(), off(), exclude(selector) }</code>.</p>
++</div>`,
++
++ 'icon-ink': () => `<div class="spec spec--ink">
++ <div class="spec-tiles">
++ <figure><span class="spec-tile spec-tile--app">${appTile}</span><figcaption>An app tile: the mark's ink is 0.58 of the tile</figcaption></figure>
++ <figure><span class="spec-tile spec-tile--box">${mark.svg({ form: 'pair', tone: 'brand', wide: true, size: 65 })}</span><figcaption>The mark at 0.58 of the tile, measured on the paint</figcaption></figure>
++ <figure><span class="spec-glyphs">${['home', 'chat', 'play', 'search', 'user', 'plus'].map((n) => `<span>${kit.icon(n)}</span>`).join('')}</span><figcaption>The glyphs at 18–24 px: one line drawing, one grid</figcaption></figure>
++ </div>
++ <p class="spec-note">The app tile uses a 0.58 painted-ink fraction of the tile, measured from the painted bounds rather than the viewBox; the icon set is normalised to one ink size so a glyph from any set sits the same beside another. Small glyphs draw their solid form; nothing is scaled by eye.</p>
++</div>`,
++
++ footer: () => `<div class="spec spec--footer">
++ <div class="spec-footer">
++ <p class="spec-online"><b>13 online</b><span class="spec-faces">${kit.mark({ name: 'Ana', size: 24 })}${kit.mark({ name: 'Kai', size: 24 })}${kit.mark({ name: 'Mia', size: 24 })}</span></p>
++ <p class="spec-tip"><b>In the last 30 minutes</b><span>4 members · 9 guests</span></p>
++ <p class="spec-fine">The count opens the names on a press; a finger never sees a tooltip, so the words are also there.</p>
++ </div>
++ <p class="spec-note">The footer's online strip says the short count, the faces and one tooltip. Hovering or focusing the count shows "In the last 30 minutes" and the members and guests beneath it; a press opens the same words above the list of names. The tooltip file is fetched by the first pointer over the bar, not with the page.</p>
++</div>`,
++};
++
++const NOTES = Object.freeze({
++ brand: {
++ title: 'The bar’s brand',
++ summary: 'The mark and the site’s name as one button: the site’s own pages, then every OpenVibe site.',
++ release: { version: '4.201.0', state: 'in review', href: '/changes#v4.201.0' },
++ shipped: 'The one button for the mark and the name is in review for the next Shared release. Until 4.200.0 the left end was a link plus a separate switcher button; the bar’s right end already holds the menu button on every width.',
++ quote: '“Make it so this whole thing becomes the drop down, not … two separate buttons … one drop down so it’s less confusing … the view all sites button … improve the sizing.” — the owner, 2026-11-10 (ask 347)',
++ rules: ['one-menu-button', 'menu-button-at-the-right', 'the-name-opens-its-pages', 'every-site-from-the-name'],
++ build: `// openvibe-shared/navbar.js: one button, its list, then every site\n<button type="button" class="ovnav-brand" id="openvibe-brand-btn"\n aria-haspopup="menu" aria-expanded="false">\n <span class="flame"><span class="ov-mark" data-size="42" data-wide></span></span>\n <span class="name">OpenVibe<span class="b-dot">.</span>Design</span>\n</button>\n// OVDrop.pages(button, navLinks, navIcon, { label: 'All OpenVibe sites', onClick: openSwitcher })`,
++ },
++ navbar: {
++ title: 'The navbar',
++ summary: 'One bar on every site: the brand at the left, the menu button at the right, and the person last.',
++ release: { version: '4.200.0', state: 'shipped', href: '/changes#v4.200.0' },
++ shipped: '4.200.0 moved the menu button to the right end on every width, made the name open its pages and put “All OpenVibe sites” at the end of that list. The brand’s one-button form is in review for the next release.',
++ quote: '“The menu button should be on the right, even on desktop … there’s like a duplicated icon.” — the owner, 2026-11-10',
++ rules: ['one-bar', 'one-menu-button', 'menu-button-at-the-right', 'the-name-opens-its-pages', 'every-site-from-the-name'],
++ build: `// openvibe-shared/navbar.js\n// left: the brand (the mark and the name); right: the menu button, then the person\n// the name opens its pages; “All OpenVibe sites” opens the menu past them`,
++ },
++ dock: {
++ title: 'The Dock',
++ summary: 'The panels beside the work, and the same panels inside a phone’s menu sheet.',
++ release: { version: '4.198.0', state: 'shipped', href: '/changes#v4.198.0' },
++ shipped: '4.198.0 let the Dock’s panels mount inside the menu on a phone (dock-mount.js), with no head of their own and the same state as the floating Dock; 4.196.0 made the Actor block one row and one field, and left out a black stage for a session that ended with no picture.',
++ quote: '“combine and consolidate these two menus into one unified menu … easier to search between.” — the owner, 2026-11-10',
++ rules: ['places-say-what-they-hold', 'menu-and-dock-meet', 'opens-it-closes-it', 'state-in-words'],
++ build: `// openvibe-shared/dock-mount.js (loaded on first use)\nconst panel = OpenVibeDock.mountInto(host, { tab: 'vibe' });\npanel.show('agents');\npanel.destroy(); // no nodes or listeners left behind`,
++ },
++ 'you-home': {
++ title: 'You home',
++ summary: 'Your face and name, add or switch account, Display, Settings, Sign out, then only what has no other home.',
++ release: { version: '4.195.0', state: 'shipped', href: '/changes#v4.195.0' },
++ shipped: '4.195.0 replaced the old account block in the menu with one slim row: your face and name, add or switch account, Display, Settings and Sign out, each a full touch target with a tooltip. Below it, small, only this site’s own items, your status and Admin.',
++ quote: '“When I said integrate the user drop down into this menu, I didn’t mean like this … get rid of this thing.” — the owner, 2026-11-10',
++ rules: ['account-in-the-menu', 'menu-opens-here', 'targets-44'],
++ build: `// openvibe-shared/navbar.js hands the menu three small things; ov-home.js draws the row\nme: { add, out, own }\n// no dropdown: the picture opens the menu on You`,
++ },
++ menu: {
++ title: 'The menu',
++ summary: 'One navigation sheet: Here, Feed, Vibe with Chat · Agents, Chat and You; the pressed button flies to its tab.',
++ release: { version: '4.200.0', state: 'shipped', href: '/changes#v4.200.0' },
++ shipped: '4.200.0 made the menu one row of places: this site, Feed, Vibe (Chat · Agents), Chat and You. The button that was pressed flies to the tab it opens and becomes it, and closing flies it back. The notifications and the avatar count are in review for the next release.',
++ quote: '“When you click this button, I want it to animate into the tab it becomes. Same with the other buttons.” — the owner, 2026-11-10; and “instead of having this called AI as the tab, let’s call it Vibe, and then under Vibe is Chat and Agents.”',
++ rules: ['places-say-what-they-hold', 'button-becomes-its-tab', 'actions-as-icons-in-the-pill', 'menu-opens-here', 'menu-and-dock-meet', 'account-in-the-menu'],
++ build: `// openvibe-shared/search.js\nOpenVibeSearch.open({ from: pressedElement });\n// each place has a stable data-tab id; the sheet's origin is the pressed button's centre\n// Vibe is the spark: its switch is Chat · Agents; Chat is the conversations; You carries the notifications`,
++ },
++ 'search-pill': {
++ title: 'The search pill',
++ summary: 'The menu’s field, and the place’s actions as icons inside it: new, open in Chat, close.',
++ release: { version: '4.200.0', state: 'shipped', href: '/changes#v4.200.0' },
++ shipped: 'A place’s actions became icons in 4.200.0: + (new) and ↗ (open in OpenVibe.Chat) sit in the search pill, left of the close button, so they cost no row. The floating Dock keeps one slim row of the same icons.',
++ quote: '“remove the text of Vibe conversations … the new conversation button can … move to an icon plus button … Open in chat should be … a little button icon.” — the owner, 2026-11-10',
++ rules: ['actions-as-icons-in-the-pill', 'say-it-once', 'targets-44'],
++ build: `// openvibe-shared/search.js: the pill's actions are the panel's own buttons, mirrored\n<span class="ovk-find">\n <button type="button" aria-label="New chat" data-ov-new-chat>+</button>\n <button type="button" aria-label="Open in OpenVibe.Chat" data-ov-open-chat>↗</button>\n <button type="button" aria-label="Close">×</button>\n</span>`,
++ },
++ 'quest-card': {
++ title: 'The quest card',
++ summary: 'One daily quest a site can show: its mark, the day’s quest, three steps and one door to OpenVibe.Quest.',
++ release: { version: 'next', state: 'in review', href: '/components/quest-card' },
++ shipped: 'The designed daily quest card any site can show is in review for the next Shared release (openvibe-shared/quest, quest.css). It draws its own marks, so a site need not load Font Awesome, and the door’s arrow takes the accent. A site reads its own server; the browser never calls OpenVibe.Quest.',
++ quote: '“Quest card: its own marks (no Font Awesome), the door’s arrow in the accent.” — the change that ships openvibe-shared/quest',
++ rules: ['only-the-pair', 'two-icon-sets', 'state-in-words', 'a-change-has-a-mark'],
++ build: `// openvibe-shared/quest.js, with quest.css beside it\nconst card = OpenVibeQuest.mount(document.getElementById('quest'), {\n load: () => fetch('/api/home/daily-quest').then((r) => r.json()),\n});\ncard.refresh(); // read again and redraw`,
++ },
++ avatar: {
++ title: 'The avatar',
++ summary: 'A person’s picture; when it cannot show, the initial it becomes instead of a broken image.',
++ release: { version: '4.201.0', state: 'in review', href: '/changes#v4.199.0' },
++ shipped: '4.199.0 gave avatars their reserved box (width, height and async decode) so a page no longer shifts. The fallback to the initial is in review for the next release: a capture listener and a sweep replace any <img data-ov-initial> that fails, CSP-safe, in the footer’s faces and the account picture.',
++ quote: '“A picture that cannot show becomes its initial (footer faces and account picture).” — the change, after a broken avatar in Locker’s footer',
++ rules: ['two-icon-sets', 'state-in-words', 'targets-44'],
++ build: `// openvibe-shared/footer.js (and the account picture)\n<img src="/avatar/ana" alt="" width="32" height="32"\n loading="lazy" decoding="async" data-ov-initial="A">\n// a failed load becomes <i class="ov-initial">A</i>; no inline handler, no extra request`,
++ },
++ player: {
++ title: 'The player',
++ summary: 'The network’s video player: one row of controls on a phone that never runs off the screen.',
++ release: { version: '4.201.0', state: 'in review', href: '/changes#v4.192.0' },
++ shipped: '4.192.0 shipped our own small player that keeps a video going while you browse. The phone row is in review for the next release: the phone rules lost to a later sizing block, so at 390 px fullscreen was cut; the fix puts the phone block last, steps speed and picture-in-picture out, shortens the volume slider and shows the position alone under 360 px.',
++ quote: '“Player on a phone: one row that never runs off the screen.” — the change, measured at 390 px on openvibe.video',
++ rules: ['player-row-never-runs-off', 'cascade-in-order', 'the-video-comes-along', 'targets-44'],
++ build: `<!-- openvibe-shared/player.css + player.js -->\n<video data-ov-player controls playsinline preload="metadata"\n src="/video.m3u8" poster="/poster.jpg"></video>\n<!-- on a phone: play, mute, volume, time, captions, fullscreen; speed and PiP step out -->`,
++ },
++ 'soft-pages': {
++ title: 'Soft pages',
++ summary: 'A page fetched on intent, its wait drawn by the mark, and a bar that holds still while the page cross-fades.',
++ release: { version: '4.201.0', state: 'in review', href: '/changes#v4.192.0' },
++ shipped: 'The soft page is in review for the next Shared release (soft.js). A same-site link is fetched on pointer down or after 80 ms of hover, never on Save-Data or 2G/3G, at most four in flight. The mark’s wait starts on the press and ends at first paint; cross-document view transitions hold the shared bar still and fade the rest (180 ms; Calm 120 ms; Off none). Measured on production pages at 390 px: press to first paint 1632 ms → 994 ms median.',
++ quote: '“soft.js: prefetch on intent, the mark on the press, cross-document view transitions” — the change; 39% / ~0.64 s less press-to-first-paint in five throttled runs',
++ rules: ['first-paint-alone', 'content-hashed-assets', 'no-spinners', 'calm-reduces-off-removes'],
++ build: `<!-- in <head>, after theme-loader.js, not deferred -->\n<script src="/shared/soft.js"></script>\n\n// a page can turn one link off, or the whole thing:\n<a href="/next" data-ov-no-prefetch>Next</a>\nOpenVibeSoft = { on(), off(), exclude(selector) };`,
++ },
++ 'icon-ink': {
++ title: 'Icon ink',
++ summary: 'A mark’s painted ink inside its tile: 0.58 of the tile, measured, not scaled by eye.',
++ release: { version: '4.201.0', state: 'in review', href: '/changes#v4.183.0' },
++ shipped: '4.183.0 gave every shape glyph a solid drawing at 18 px and under. The normalisation in review measures every shared icon set in Chrome, normalises 666 drawings to one ink size (0.79), and uses a 0.58 painted-ink fraction for app tiles; the old oversized tile rule is gone.',
++ quote: '“Use a 0.58 painted-ink fraction for app tiles and remove the old oversized tile rule.” — the change, with the 50/58/64% comparison sheet',
++ rules: ['two-icon-sets', 'only-the-pair', 'targets-44'],
++ build: `// openvibe-shared/app-icon.js: the favicon's heavy form on a rounded tile\nfavicon({ site }) // mark(site, 0.58, 'heavy')\n\n// the test measures the painted bounds in headless Chrome, not the viewBox:\ntest/icon-ink.test.js // ink within 4% of the target, centre within 3%`,
++ },
++ footer: {
++ title: 'The footer',
++ summary: 'The strip that says how many are online, the faces, and one tooltip that says what the count means.',
++ release: { version: '4.197.0', state: 'shipped', href: '/changes#v4.197.0' },
++ shipped: '4.197.0 made the footer say “13 online”, then the faces; hovering or focusing the count shows “In the last 30 minutes” and the members and guests, and a press opens the same words above the names. A guests-only strip opens that line too.',
++ quote: '“Make it like shorthand ‘3 online’. If you put your mouse over it, it says ‘last 30 minutes’.” — the owner, 2026-11-10',
++ rules: ['one-bar', 'state-in-words', 'say-it-once', 'targets-44'],
++ build: `// openvibe-shared/footer.js\n// one read: /api/frame/online → { count, members, guests, faces, live }\n// the count carries data-ov-tip="In the last 30 minutes"; a press opens the names`,
++ },
++});
++
++/** One note page, laid out like the component pages beside it. */
++function notePage(name) {
++ const note = NOTES[name];
++ if (!note) return null;
++ const specimen = SPECIMENS[name] ? SPECIMENS[name]() : '';
++ const rules = ruleChips(note.rules);
++ const release = note.release.state === 'shipped'
++ ? `<a href="${esc(note.release.href)}">Shipped in ${esc(note.release.version)}</a>`
++ : `In review for the next Shared release (${esc(note.release.version)}) · <a href="/changes">the archive</a>`;
++ const body = `<div class="ds-wrap cn">
++<header class="ds-page-h"><p class="ds-kicker"><a href="/components/registry">Components</a></p><h1>${esc(note.title)}</h1>
++<p class="ds-lede">${esc(note.summary)}</p>
++<p class="cn-meta">${release} · <a href="/changes">Every Shared release</a> · <a href="/rules">The rules</a></p></header>
++<section class="cn-spec" aria-labelledby="cn-spec-h"><div class="cn-head"><h2 id="cn-spec-h">The specimen</h2><p class="ds-fine">${note.release.state === 'shipped' ? 'Rendered by this site from the openvibe-shared it pins.' : 'The part is in review for the next Shared release; the specimen is here so the design can be seen.'}</p></div>
++<div data-specimen>${specimen}</div></section>
++<div class="cn-cols">
++<section class="cn-shipped" aria-labelledby="cn-shipped-h"><h2 id="cn-shipped-h">What shipped</h2><p>${esc(note.shipped)}</p><blockquote>${esc(note.quote)}</blockquote></section>
++<section class="cn-rules" aria-labelledby="cn-rules-h"><h2 id="cn-rules-h">The rules it carries out</h2><p class="cn-chips">${rules || '<span class="ds-fine">No rule has been written for this part yet.</span>'}</p>
++<h2>Build it</h2>${kit.snippet({ code: note.build, id: `cn-build-${name}` })}</section>
++</div>
++<footer class="cn-foot"><p><a href="/changes">Every Shared release, and the component it changed</a> · <a href="/components/registry">Every component</a> · <a href="/rules">How OpenVibe decides</a></p></footer>
++</div>`;
++ return layout({
++ title: `${note.title}: OpenVibe component`,
++ description: note.summary,
++ path: `/components/${name}`,
++ active: '/components',
++ styles: ['css/component-registry.css', 'css/specimens.css'],
++ sharedStyles: name === 'player' ? ['player.css'] : [],
++ shared: name === 'player' ? ['player.js'] : [],
++ body,
++ });
++}
++
++/** A page's status and rules as one section, for the designed pages that shipped before these notes (navbar, menu). */
++function shippedSection(name) {
++ const note = NOTES[name];
++ if (!note) return '';
++ const release = note.release.state === 'shipped'
++ ? `<a href="${esc(note.release.href)}">Shipped in ${esc(note.release.version)}</a>`
++ : `In review for the next Shared release (${esc(note.release.version)})`;
++ return `<section class="cn-shipped cn-shipped--inline" aria-labelledby="cn-shipped-${esc(name)}-h"><h2 id="cn-shipped-${esc(name)}-h">What shipped</h2>
++<p>${esc(note.shipped)}</p><blockquote>${esc(note.quote)}</blockquote>
++<p class="cn-chips">${ruleChips(note.rules)}</p><p class="cn-meta">${release} · <a href="/changes">Every Shared release</a></p></section>`;
++}
++
++const specimen = (name) => (SPECIMENS[name] ? SPECIMENS[name]() : '');
++const list = () => Object.entries(NOTES).filter(([name]) => !['menu', 'navbar', 'dock', 'you-home'].includes(name)).map(([name, note]) => ({ name, title: note.title, summary: note.summary }));
++
++module.exports = { notePage, shippedSection, specimen, list, NOTES };
ddiff --git a/server/views/component-registry.js b/server/views/component-registry.js
iindex 47a938b..58efc54 100644
---- a/server/views/component-registry.js
++++ b/server/views/component-registry.js
@@@ -4,11 +4,12 @@ const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const { COMPONENTS, get } = require('../components/registry');
const { layout, esc } = require('../render/layout');
++const { shippedSection, specimen, list: noteList } = require('./component-notes');
const link = (name) => `/components/${name}`;
const demo = {
navbar: () => `<nav class="cr-mini-nav" aria-label="Demo navigation"><a href="/">${kit.icon('home')} OpenVibe</a><a href="/themes">Themes</a><details><summary>More</summary><a href="/look">Your look</a><a href="/components">Components</a></details></nav>`,
-- menu: () => `<details class="cr-menu"><summary class="ovk-btn ovk-btn--quiet">${kit.icon('menu')} Open menu</summary>${kit.row({ href: '/look', title: 'You', sub: 'Your look and settings', mark: kit.mark({ name: 'You' }) })}${kit.row({ href: '/themes', title: 'Studio', sub: 'Choose a theme', mark: kit.mark({ name: 'Studio' }) })}</details>`,
++ menu: () => specimen('menu'),
'you-home': () => kit.section({ title: 'You', note: 'Your places in one menu', body: kit.list([kit.row({ href: '/look', title: 'Your look', sub: 'Theme and comfort settings', mark: kit.mark({ name: 'You' }) }), kit.row({ href: '/themes', title: 'Theme studio', sub: 'Make it yours', mark: kit.mark({ name: 'Studio' }) })]) }),
dock: () => kit.dock({ action: '#', id: 'cr-dock', label: 'Message', placeholder: 'Write a message' }),
'dock-tabs': () => `<nav class="cr-dock-tabs" aria-label="Demo app tabs"><a href="/">${kit.icon('home')} Home</a><a href="/themes" aria-current="page">${kit.icon('palette')} Studio</a><a href="/look">${kit.icon('user')} You</a></nav>`,
@@@ -51,11 +52,15 @@ function registryPage() {
return `<li class="cr-card"><a class="cr-card-link" href="${link(name)}"><b>${esc(c.title)}</b><span>${esc(c.summary)}</span></a><div class="cr-card-meta"><span class="cr-card-platforms">${platforms}</span><a href="/api/v1/components/${esc(name)}" aria-label="${esc(c.title)} as JSON">JSON</a></div></li>`;
}).join('');
const sections = GROUPS.map(([title, names]) => `<section class="cr-group"><h2>${esc(title)}</h2><ul class="cr-cards">${cards(names)}</ul></section>`).join('');
++ const notes = noteList();
++ const shipped = notes.length ? `<section class="cr-group cr-group--shipped" id="crl-shipped"><h2>Changed lately: what shipped</h2><p class="crl-group-l">The bar's brand, the search pill, the quest card, the avatar, the player, soft pages, icon ink and the footer: one specimen and the release behind each.</p><ul class="cr-cards">${notes.map((n) => `<li class="cr-card crl-card"><a class="crl-note-link" href="/components/${esc(n.name)}"><b>${esc(n.title)}</b><span>${esc(n.summary)}</span></a><div class="cr-card-meta"><span class="crl-counts">Shared release</span><a href="/changes">Changes</a></div></li>`).join('')}</ul></section>` : '';
const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>One vocabulary, three platforms</h1><p class="ds-lede">Every shared OpenVibe interface as one definition: its anatomy, props, states, tokens, keyboard and dismissal rules, accessibility, and how it is built on the web, in Jetpack Compose and in SwiftUI. The same definitions are the vocabulary Design and Space give their AI generators.</p>
<p class="cr-links"><a href="/api/v1/components">All definitions as JSON</a> · <a href="/llms.txt">llms.txt</a> · <a href="/api/v1/components/sheet">Example: sheet</a></p></header>
${sections}
--<p class="ds-fine">A definition is a promise about behaviour, not a screenshot: the live demo on each page is the web implementation, and the Android and iOS sections name the container role and the interaction each platform owns.</p>`;
-- return layout({ title: 'Component registry: OpenVibe design system', description: 'Platform-neutral definitions of the OpenVibe navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion: web, Android and iOS.', path: '/components/registry', body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'] });
++${shipped}
++<p class="ds-fine">A definition is a promise about behaviour, not a screenshot: the live demo on each page is the web implementation, and the Android and iOS sections name the container role and the interaction each platform owns.</p>
++<footer class="cn-foot"><p><a href="/changes">Every Shared release, and the component it changed</a> · <a href="/rules">How OpenVibe decides</a></p></footer>`;
++ return layout({ title: 'Component registry: OpenVibe design system', description: 'Platform-neutral definitions of the OpenVibe navbar, menu, You home, Dock and tabs, thread composer, sheets, pop-overs, keyboard behaviour, kit rows, cards, sections, empty states, switches, segmented controls, uploads, the model chip and motion: web, Android and iOS.', path: '/components/registry', body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css', 'css/specimens.css'] });
}
function componentPage(name) {
@@@ -74,13 +79,15 @@ function componentPage(name) {
const details = `<dl class="cr-facts">${facts.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;
const native = (platform) => `<section class="cr-native"><h3>${platform === 'android' ? 'Android · Jetpack Compose' : 'iOS · SwiftUI'}</h3><p>${esc(platform === 'android' ? c.platforms.android.specification : c.platforms.ios.specification)}</p>${platform === 'android' ? `<p>Material 3 container: <code>${esc(c.platforms.android.material3)}</code></p>` : ''}</section>`;
const body = `<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>${esc(c.title)}</h1><p class="ds-lede">${esc(c.summary)}</p><p><a href="/components/registry">All components</a> · <a href="/components">Kit documents</a> · <a href="/api/v1/components/${esc(c.name)}">Definition as JSON</a></p></header>
--<div class="cr-layout"><section><h2>Live web demo</h2><div class="cr-demo cr-demo--${esc(c.name)}" data-ov-component="${esc(c.name)}">${demo[c.name]()}</div><p class="ds-fine">Web source: <code>${esc(c.platforms.web.module)}</code> · ${esc(c.platforms.web.markup)}</p></section>
++${shippedSection(c.name)}
++<div class="cr-layout"><section><h2>Live web demo</h2><div class="cr-demo cr-demo--${esc(c.name)}" data-ov-component="${esc(c.name)}" data-specimen>${demo[c.name]()}</div><p class="ds-fine">Web source: <code>${esc(c.platforms.web.module)}</code> · ${esc(c.platforms.web.markup)}</p></section>
<section><h2>Anatomy</h2><ul class="cr-anatomy">${anatomy}</ul><h2>Props</h2><dl class="cr-props">${Object.entries(c.props).map(([key, type]) => `<div><dt><code>${esc(key)}</code></dt><dd>${esc(type)}</dd></div>`).join('')}</dl></section></div>
<section><h2>Behavior and accessibility</h2>${details}</section>
<div class="cr-layout">${native('android')}${native('ios')}</div>
<section><h2>Example definition</h2>${kit.snippet({ code: JSON.stringify({ schema: c.schema, name: c.name, props: c.examples[0].props }, null, 2), id: 'cr-example' })}</section>
--<h2>Other components</h2>${registryLinks()}`;
-- return layout({ title: `${c.title}: OpenVibe component`, description: c.summary, path: link(c.name), body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css'], scripts: ['js/component-registry.js'], sharedStyles: c.name === 'thread-composer' ? ['thread.css'] : [], shared: c.name === 'thread-composer' ? ['ov-thread.js'] : [] });
++<h2>Other components</h2>${registryLinks()}
++<footer class="cn-foot"><p><a href="/changes">Every Shared release, and the component it changed</a> · <a href="/rules">How OpenVibe decides</a></p></footer>`;
++ return layout({ title: `${c.title}: OpenVibe component`, description: c.summary, path: link(c.name), body: `<div class="ds-wrap tk cr">${body}</div>`, active: '/components', styles: ['css/component-registry.css', 'css/specimens.css'], scripts: ['js/component-registry.js'], sharedStyles: c.name === 'thread-composer' ? ['thread.css'] : [], shared: c.name === 'thread-composer' ? ['ov-thread.js'] : [] });
}
module.exports = { componentPage, registryPage, registryLinks };
ddiff --git a/server/views/components-list.js b/server/views/components-list.js
iindex 3e40464..55329fa 100644
---- a/server/views/components-list.js
++++ b/server/views/components-list.js
@@@ -51,7 +51,7 @@ function componentsListPage(entries) {
const body = `<div class="ds-wrap tk cr crl">
<header class="ds-page-h"><p class="ds-kicker">Component registry</p><h1>Every part of OpenVibe, defined once</h1>
<p class="ds-lede">${plural(entries.length, 'component')} from the shared kit, each with its props, states, tokens, behaviour, accessibility and how it is built on the web, Android and iOS.</p>
--<p class="cr-links"><a href="/components/registry">The interface pages</a> · <a href="/api/v1/components">All of it as JSON</a> · <a href="/kit">The kit</a></p></header>
++<p class="cr-links"><a href="/components/registry">The interface pages</a> · <a href="/changes">What shipped</a> · <a href="/api/v1/components">All of it as JSON</a> · <a href="/kit">The kit</a></p></header>
${nav}
${groups.length ? groups.map((g) => `<section class="cr-group crl-group" id="crl-${g.id}" aria-labelledby="crl-${g.id}-h"><header class="crl-group-h"><h2 id="crl-${g.id}-h">${esc(g.title)}</h2><p>${esc(g.note)}</p></header><ul class="cr-cards">${g.entries.map(card).join('')}</ul></section>`).join('')
: kit.empty({ icon: 'grid', title: 'No components imported yet', text: 'The registry fills from the installed openvibe-shared when the service starts.' })}
@@@ -108,6 +108,7 @@ ${anatomy ? `<h2>Anatomy</h2><ul class="cr-anatomy">${anatomy}</ul>` : ''}</sect
<section class="cr-native"><h3>iOS${ios.toolkit ? ` · ${esc(ios.toolkit)}` : ''}</h3><p>${esc(ios.specification || 'Not specified yet.')}</p></section>
</div>
${examples(entry)}
++<footer class="cn-foot"><p><a href="/changes">Every Shared release, and the component it changed</a> · <a href="/components/registry">The interface pages</a> · <a href="/rules">How OpenVibe decides</a></p></footer>
</div>`;
return layout({
title: `${c.title || entry.name}: OpenVibe component`,
ddiff --git a/server/views/components.js b/server/views/components.js
iindex 43a341c..cb0ca8d 100644
---- a/server/views/components.js
++++ b/server/views/components.js
@@@ -88,14 +88,15 @@ ${kit.tabs({ base: `${base}`, param: 'p', active: p, label: 'Platform', items: P
<p class="tk-how">${esc(how)}</p>
${kit.snippet({ code, id: 'cp-code-snip', label: 'Copy the code' })}</section>
<section class="cr"><h2>Shared interfaces</h2><p>Web, Android and iOS definitions for the navigation, composer, sheets, settings and motion used across OpenVibe. <a href="/components/registry">See the whole registry</a>, or read it as JSON at <code>/api/v1/components</code>.</p>${registryLinks()}</section>
--${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}`;
++${kit.fold({ id: 'cp-docs', title: 'Kit documents: screens as data', body: `<p>A screen can be sent as a tree of these components, for example <code>{ "type": "rail", "children": [{ "type": "card", … }] }</code>. Every OpenVibe client renders the same document with its own views, so a screen can change without an app release (the server-driven UI pattern of Airbnb and DivKit). Documents are checked strictly: known components and props only, typed values, which children each takes, a size limit, and https or same-site links.</p><p>${kit.button({ href: '/api/v1/components', label: 'The specs as JSON', kind: 'quiet', small: true })} ${kit.button({ href: '/tokens', label: 'The tokens they wear', kind: 'quiet', small: true })}</p>` })}
++<footer class="cn-foot"><p><a href="/changes">Every Shared release, and the component it changed</a> · <a href="/components/registry">The interface pages</a> · <a href="/rules">How OpenVibe decides</a></p></footer>`;
return layout({
title: `${spec.name}: an OpenVibe component for web, Android and iOS`,
description: `${spec.summary} Its parts, tokens, states and accessibility, with code for the web, Jetpack Compose and SwiftUI.`,
path: `/components?c=${encodeURIComponent(spec.id)}`,
body: `<div class="ds-wrap tk">${body}</div>`,
active: '/components',
-- styles: ['css/component-registry.css'],
++ styles: ['css/component-registry.css', 'css/specimens.css'],
});
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex c844463..466276b 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -99,6 +99,7 @@ function home({ themes } = {}) {
const { patterns } = require('../patterns/catalog');
const glyphNames = (() => { try { return require('openvibe-shared/glyphs').NAMES.length; } catch { return 0; } })();
const componentCount = (() => { try { return Object.keys(require('./components').SAMPLES).length; } catch { return 0; } })();
++ const releaseCount = (() => { try { return require('../changes').releases().length; } catch { return 0; } })();
const SYSTEM = [
{ href: '/themes', name: 'Themes', count: all.length, unit: 'themes', text: 'Colour, type and shape for every site at once, each checked for contrast.', glyph: 'palette' },
{ href: '/icons', name: 'Icons', count: glyphNames, unit: 'icons', text: 'One line, one grid, in the text’s colour; draw new ones with AI.', glyph: 'shapes' },
@@@ -108,6 +109,7 @@ function home({ themes } = {}) {
{ href: '/motion', name: 'Motion', count: (() => { try { return require('./motion').EFFECTS.length; } catch { return 0; } })(), unit: 'effects', text: 'Glint, glow, lift, press, ripple: one kit of effects and one set of curves.', glyph: 'motion' },
{ href: '/tokens', name: 'Tokens', count: 7, unit: 'formats', text: 'Any theme as CSS, Tailwind, Compose, SwiftUI, Flutter, Figma or DTCG.', glyph: 'code' },
{ href: '/native', name: 'Apps', count: 3, unit: 'platforms', text: 'From the web to Android and iOS, without a frame: the road and the rules.', glyph: 'apps' },
++ { href: '/changes', name: 'Changes', count: releaseCount, unit: 'releases', text: 'Every shared release, newest first, with the pages and rules it touched and the screenshots it left.', glyph: 'rules' },
];
const GLYPH = {
palette: 'M12 3a9 9 0 1 0 0 18c1 0 1.6-.8 1.6-1.6 0-.5-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1 0-.9.7-1.6 1.6-1.6H16a5 5 0 0 0 5-5c0-4.1-4-7.6-9-7.6ZM7.5 12.5h.01M9.5 8h.01M14.5 8h.01M17 11.5h.01',
@@@ -156,6 +158,7 @@ function home({ themes } = {}) {
{ title: 'Tokens', text: 'Any theme as CSS, Compose, SwiftUI, Flutter or Figma', href: '/tokens', icon: 'ov:code' },
{ title: 'Create', text: 'Three AI drafts on your theme', href: '/create', icon: 'ov:ai' },
{ title: 'Your look', text: 'Light or dark, text size, motion, seasons and comfort settings, for every site', href: '/look', icon: 'ov:account' },
++ { title: 'Changes', text: 'Every OpenVibe.Shared release, the pages it touched and the rules it names', href: '/changes', icon: 'ov:history' },
] },
html: system + (strip ? `<section aria-labelledby="ds-strip-h"><div class="ds-head-row"><h2 id="ds-strip-h">Themes to wear</h2><a class="ds-more" href="/themes">All ${all.length} in the studio${kit.icon('chevron')}</a></div><ul class="ds-strip">${strip}</ul></section>` : ''),
faq: { items: [
ddiff --git a/server/views/rules.js b/server/views/rules.js
iindex b1d0e52..c4094de 100644
---- a/server/views/rules.js
++++ b/server/views/rules.js
@@@ -19,7 +19,7 @@ function rule(r) {
const see = r.see.map((s) => `<a class="rl-chip" href="${esc(s.href)}"${isExternal(s.href) ? ' rel="noopener"' : ''}>${esc(s.label)}</a>`).join('');
const checked = r.checked.map((c) => `<span class="rl-chip rl-chip--check">${kit.icon('check')}${esc(c)}</span>`).join('');
return `<article class="rl-rule" id="${esc(r.id)}" data-rl-text="${esc(`${r.rule} ${r.detail} ${r.why}`.toLowerCase())}">
-- <header class="rl-rule-head"><h3><a href="#${esc(r.id)}">${esc(r.rule)}</a></h3><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time></header>
++ <header class="rl-rule-head"><h3><a href="#${esc(r.id)}"><span class="rl-num" aria-label="Rule ${esc(String(r.n))}">${esc(String(r.n))}</span>${esc(r.rule)}</a></h3><time datetime="${esc(r.decided)}">${esc(day(r.decided))}</time></header>
<p class="rl-detail">${esc(r.detail)}</p>
<p class="rl-why"><span>Why</span> ${esc(r.why)}</p>
${see || checked ? `<p class="rl-links">${see ? `<span class="rl-label">See</span>${see}` : ''}${checked ? `<span class="rl-label">Checked by</span>${checked}` : ''}</p>` : ''}
ddiff --git a/test/changes-chrome.test.js b/test/changes-chrome.test.js
nnew file mode 100644
iindex 0000000..3d3c806
---- /dev/null
++++ b/test/changes-chrome.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// The component pages and /changes in real Chrome at 390 and 1280: no console error, no sideways scroll, one request
++// per script. The Frame's two reads of the live Network are cross-origin from the test's local app and are the only
++// ignored errors: everything a component page itself loads is local and must be clean.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const harness = require('openvibe-shared/browser-harness');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the component pages and /changes render with no console errors at 390 and 1280', async () => {
++ const routes = ['/changes', '/components/registry', '/components/brand', '/components/menu', '/components/search-pill',
++ '/components/quest-card', '/components/avatar', '/components/player', '/components/soft-pages', '/components/icon-ink', '/components/footer'];
++ const report = await harness.run({
++ base: t.base,
++ routes,
++ widths: [390, 1280],
++ checks: { status: true, errors: true, overflow: true, scripts: true, nojs: false, canonical: false, jsonld: false, axe: false, text: false, navigation: false },
++ ignoreErrors: [
++ // The shared Frame asks the live Network for the person's theme, the site record, the online strip
++ // and the pulse. The test's app is local, so those three reads are cross-origin and refused; they
++ // are the Frame's, not a specimen's, and every local file a page loads is still checked.
++ { label: 'the shared Frame reads the live Network API', text: /openvibe\.network\/api\/(themes|frame)(?:\/|\?)/ },
++ { label: 'the shared Frame reads the live Network API', url: /openvibe\.network\/api\/(themes|frame)(?:\/|\?)/ },
++ ],
++ });
++ assert.ok(report.ok, `\n${harness.format(report)}`);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/changes.test.js b/test/changes.test.js
nnew file mode 100644
iindex 0000000..6b3dba6
---- /dev/null
++++ b/test/changes.test.js
@@@ -0,0 +1,122 @@
++'use strict';
++// /changes: every release in the openvibe-shared this site pins, newest first, with its pages, rules and plates.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++const changes = require('../server/changes');
++require('../server/views/changes');
++require('../server/views/component-notes');
++require('../server/views/component-registry');
++require('../server/views/components-list');
++require('../server/views/components');
++require('../server/http/api');
++const { spawnSync } = require('child_process');
++const { PUBLIC_DIR } = require('../server/assets');
++
++(async () => {
++ const t = await boot();
++ try {
++ const all = changes.releases();
++
++ await check('/changes lists every release in the pinned CHANGELOG, newest first, in its own words', async () => {
++ const r = await t.get('/changes');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Every shared release, and where it shows/);
++ assert.match(r.text, new RegExp(`openvibe-shared@${changes.SHARED_VERSION.replace(/\./g, '\\.')}`));
++ assert.strictEqual((r.text.match(/data-release="/g) || []).length, all.length, `${all.length} releases`);
++ let last = -1;
++ for (const release of all) {
++ const at = r.text.indexOf(`data-release="${release.version}"`);
++ assert.ok(at > last, `${release.version} is in order`);
++ last = at;
++ }
++ assert.ok(r.text.includes('The button becomes its tab; Vibe holds Chat and Agents'), '4.200.0 as written');
++ assert.ok(r.text.includes('navbar.js no longer ships every glyph'), '1.0.0 as written');
++ });
++
++ await check('each release links the component pages it touches, and every link answers', async () => {
++ const r = await t.get('/changes');
++ const links = [...new Set([...r.text.matchAll(/href="(\/components\/[a-z0-9-]+)"/g)].map((m) => m[1]))];
++ assert.ok(links.length >= 8, `${links.length} component pages linked`);
++ for (const href of links) assert.strictEqual((await t.get(href)).status, 200, href);
++ assert.match(r.text, /href="\/components\/menu"/);
++ assert.match(r.text, /href="\/components\/brand"/);
++ assert.match(r.text, /href="\/components\/player"/);
++ assert.match(r.text, /href="\/components\/quest-card"/);
++ });
++
++ await check('the rule numbers are the register’s own, and each links to its rule', async () => {
++ const r = await t.get('/changes');
++ const { RULES } = require('../server/rules/register');
++ const rule = RULES.find((x) => x.id === 'button-becomes-its-tab');
++ assert.ok(rule && rule.n > 0, 'numbered');
++ assert.match(r.text, new RegExp(`href="/rules#button-becomes-its-tab"[^>]*><b>${rule.n}</b>`));
++ assert.match(r.text, new RegExp(`href="/rules#places-say-what-they-hold"[^>]*><b>${RULES.find((x) => x.id === 'places-say-what-they-hold').n}</b>`));
++ });
++
++ await check('every screenshot path named exists, and 4.195.0 through 4.200.0 each have plates', async () => {
++ const r = await t.get('/changes');
++ const paths = [...new Set([...r.text.matchAll(/\/changes\/(\d+\.\d+\.\d+)\/([a-z0-9-]+-(?:390|1280)-(?:dark|light)\.webp)/g)].map((m) => m[0]))];
++ assert.ok(paths.length >= 40, `${paths.length} plates named`);
++ for (const p of paths) assert.ok(fs.existsSync(path.join(PUBLIC_DIR, p)), p);
++ for (const v of ['4.195.0', '4.196.0', '4.197.0', '4.198.0', '4.199.0', '4.200.0']) {
++ assert.ok(paths.some((p) => p.startsWith(`/changes/${v}/`)), `${v} has plates`);
++ assert.ok(fs.existsSync(path.join(PUBLIC_DIR, 'changes', v)), `${v} folder`);
++ }
++ const { screenshots } = changes;
++ const shots = screenshots('4.200.0');
++ assert.ok(shots.length >= 3, '4.200.0 names the bar, the menu and the search pill');
++ for (const g of shots) assert.strictEqual(g.files.length, 4, `${g.component}: 390 and 1280, dark and light`);
++ });
++
++ await check('/api/v1/changes is the same archive as data, CORS open, cached', async () => {
++ const r = await t.get('/api/v1/changes');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ assert.match(r.headers.get('cache-control'), /max-age=3600/);
++ const body = r.json();
++ assert.strictEqual(body.schema, 'openvibe.changes@1');
++ assert.strictEqual(body.pinned.version, changes.SHARED_VERSION);
++ assert.strictEqual(body.releases.length, all.length);
++ assert.strictEqual(body.releases[0].version, all[0].version);
++ assert.deepStrictEqual(body.releases[0].pages.map((p) => p.key), ['brand', 'menu', 'search-pill', 'dock']);
++ assert.ok(body.releases[0].rules.some((x) => x.id === 'button-becomes-its-tab' && x.n > 0));
++ });
++
++ await check('the home and every component page link the changes archive', async () => {
++ assert.match((await t.get('/')).text, /href="\/changes"/, 'the home links it');
++ const pages = ['/components/registry', '/components/brand', '/components/menu', '/components/navbar', '/components/search-pill',
++ '/components/quest-card', '/components/avatar', '/components/player', '/components/soft-pages', '/components/icon-ink', '/components/footer'];
++ for (const href of pages) {
++ const r = await t.get(href);
++ assert.strictEqual(r.status, 200, href);
++ assert.match(r.text, /href="\/changes"/, `${href} links the archive`);
++ }
++ const registry = await t.get('/components/registry');
++ for (const name of ['brand', 'search-pill', 'quest-card', 'avatar', 'player', 'soft-pages', 'icon-ink', 'footer']) {
++ assert.match(registry.text, new RegExp(`href="/components/${name}"`), name);
++ }
++ });
++
++ await check('the capture script refuses a run with no release and names its usage', async () => {
++ const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'capture-release.js')], { encoding: 'utf8' });
++ assert.strictEqual(r.status, 2);
++ assert.match(r.stderr, /usage: node scripts\/capture-release\.js/);
++ });
++
++ await check('a light or dark plate has the built-in theme’s own tokens, forced over the account’s', async () => {
++ const css = changes.schemeCss('light');
++ assert.match(css, /--ov-color-bg-base:\s*#[0-9a-f]{6}\s*!important/);
++ assert.match(css, /--bg-primary:\s*var\(--ov-color-bg-base\)\s*!important/);
++ assert.match(css, /color-scheme:\s*light/i);
++ const r = await t.get('/scheme.css?scheme=light');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /text\/css/);
++ assert.strictEqual(r.text, css);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });