openvibers/openvibe.design Commits

Chat on openvibe.design: rule 138 and the shared chat, live (openvibe-shared 4.217.0) (#292)

OP OpenVibers committed 1 days ago · 20938bb537 · 11 files, +97 −16

* openvibe-shared v4.216.0 (was v4.211.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v4.216.0 release.

* Chat on openvibe.design: rule 138 (a message's actions at its corner) and the shared chat, live

The owner, 11-12, with phone screenshots of a stream's chat: "The edit button needs
improvement, and also openvibe.design … integration, so it never happens again. And
then also the chat on openvibe.design … cross-site … modular system for it."

- Rule 138, message-actions-at-the-corner: reply, edit, translate, copy and delete
  are one labelled cluster at a message's corner (hover or focus on a computer, a tap
  on a phone, thumb-sized there), never in its words; editing happens in the chat box
  under an "Editing your message" bar; "edited" is one quiet word after the words. It
  names the tests that hold it (Shared messages and composer-v2-chrome, Live
  chat-msg-actions).
- /components/chat: the Chat definition (anatomy, props, states, Android and iOS)
  and its live demo built from openvibe-shared's own messages.js, messages.css and
  ov-composer.js (4.216.0), as every site's chat uses them: room lines or bubbles,
  the action cluster, Reply and Edit in the box. The demo keeps its messages on the
  page.
- Pins openvibe-shared v4.216.0 (the demo needs its message actions and the
  composer's edit and reply bars).

* openvibe-shared v4.217.0 (was v4.216.0): the chat box's editing bar on its own line, for /components/chat

* Changes archive checks read every release's page: a Shared pin moves older releases past the first twelve

Changes

ddiff --git a/README.md b/README.md
iindex 77925a9..e294905 100644
---- a/README.md
++++ b/README.md
@@@ -234,6 +234,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
- openvibe-contracts: v0.406.0
- openvibe-sdk: v0.150.0
--- openvibe-shared: v4.211.0
++- openvibe-shared: v4.217.0
- openvibe-publishing: v1.7.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex aa298a8..18fdfbf 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,12 +10,12 @@
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.406.0",
"sdk": "openvibe-sdk v0.150.0",
-- "shared": "openvibe-shared v4.211.0",
++ "shared": "openvibe-shared v4.217.0",
"packages": [
"openvibe-contracts v0.406.0",
"openvibe-publishing v1.7.0",
"openvibe-sdk v0.150.0",
-- "openvibe-shared v4.211.0"
++ "openvibe-shared v4.217.0"
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
@@@ -73,5 +73,5 @@
"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-11"
++ "updated": "2026-11-12"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex fbdeccf..c1d821e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
@@@ -1476,9 +1476,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.211.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
-- "integrity": "sha512-3qMNLpByLNQPoCb9HGirVM5UTIPVYMLNshyhB185w1djaF3MLKnKahgwDpSlO3L8MAvhr+ZDzhyaedQdqTWcBQ==",
++ "version": "4.217.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
++ "integrity": "sha512-g/0RZxFLO1PDUL5lErHSkg5CfwOxQGHzzuqYlvJiVDYFtzq1lZOJjTyf8suJu+adkoNxO2xBTLZGprETmzSM1g==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 957904d..82c723d 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
"pg": "^8.23.1",
"subset-font": "^2.9.0",
"wawoff2": "^2.0.1"
ddiff --git a/public/css/component-registry.css b/public/css/component-registry.css
iindex dd9f959..93a8a59 100644
---- a/public/css/component-registry.css
++++ b/public/css/component-registry.css
@@@ -117,3 +117,10 @@
.crf-options dd { margin: 0; color: var(--ovk-muted); font-size: .86rem; line-height: 1.45; }
.crf kbd { padding: 1px 5px; border: 1px solid var(--ovk-line); border-radius: 5px; background: var(--ovk-panel-2); font-size: .78rem; }
@media (max-width: 640px) { .crf-stage { height: 300px; } .crf-state { margin-left: 0; } }
++
++/* /components/chat: the shared chat, live (rule 138). */
++.cr-chat { display: grid; gap: 10px; max-width: 620px; }
++.cr-chat-views { display: flex; gap: 6px; }
++.cr-chat-views [aria-pressed="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent); color: var(--text-primary); }
++.cr-chat .ovm-messages { max-height: 360px; overflow: auto; padding-top: 18px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-primary, #0b0f19); }
++.cr-chat .ovm-messages[data-view="room"] { gap: 4px; }
ddiff --git a/public/js/component-chat.js b/public/js/component-chat.js
nnew file mode 100644
iindex 0000000..a17d100
---- /dev/null
++++ b/public/js/component-chat.js
@@@ -0,0 +1,46 @@
++/* The chat component's live demo (/components/chat): Shared's messages.js and ov-composer.js, exactly as every site's
++ chat uses them (Live's stream chat, OpenVibe.Chat's rooms, Vibe). A message's actions are one cluster at its corner
++ (rule 138); Edit puts the words in the box under an "Editing your message" bar; Reply names who. Nothing leaves this
++ page: the demo keeps its messages here. */
++(function () {
++ 'use strict';
++ function start() {
++ const list = document.getElementById('cr-chat-list');
++ const box = document.getElementById('cr-chat-box');
++ if (!list || !box || !window.OVMessages || !window.OpenVibeComposer) return;
++ const me = 'you';
++ const ago = (min) => new Date(Date.now() - min * 60000).toISOString();
++ const messages = [
++ { id: 1, sender_id: 'maticus', display_name: 'Maticus', message: 'Live now: building the robot arm', created_at: ago(9) },
++ { id: 2, sender_id: 'you', display_name: 'You', message: 'Yo', created_at: ago(8) },
++ { id: 3, sender_id: 'lucia', display_name: 'lucía', message: '¿A qué hora termina el stream?', created_at: ago(6), lang: 'Spanish', english: 'What time does the stream end?' },
++ { id: 4, sender_id: 'maticus', display_name: 'Maticus', message: 'about an hour 🙂', created_at: ago(5) },
++ { id: 5, sender_id: 'you', display_name: 'You', message: 'The edit button sits at the corner now', created_at: ago(4), edited_at: ago(3) },
++ ];
++ let view = 'room';
++ let composer = null;
++ const offer = (m, { mine }) => (mine ? ['reply', 'edit', 'copy'] : m.english ? ['reply', 'translate', 'copy'] : ['reply', 'copy']);
++ const draw = () => OVMessages.render(list, { messages, view, me, actions: offer, onAction });
++ function onAction(id, m) {
++ if (id === 'reply') composer.reply({ name: m.display_name, text: m.message, data: { id: m.id, name: m.display_name, text: m.message } });
++ else if (id === 'edit') composer.edit({ text: m.message, onSave: (text) => { m.message = text; m.edited_at = new Date().toISOString(); draw(); } });
++ else if (id === 'translate' && m.english) { m.message = `${m.english} (from ${m.lang})`; delete m.english; draw(); }
++ else if (id === 'copy' && navigator.clipboard) navigator.clipboard.writeText(m.message).catch(() => {});
++ }
++ composer = OpenVibeComposer.mount(box, {
++ preset: 'chat', id: 'cr-chat-composer', placeholder: 'Write a message', noDraft: true,
++ onSubmit: (v) => {
++ messages.push({ id: Date.now(), sender_id: me, display_name: 'You', message: v.text, created_at: new Date().toISOString(), reply_to: v.reply ? { message: `${v.reply.name}: ${v.reply.text}` } : null });
++ draw();
++ list.scrollTop = list.scrollHeight;
++ },
++ });
++ document.querySelectorAll('[data-cr-chat-view]').forEach((b) => b.addEventListener('click', () => {
++ view = b.dataset.crChatView;
++ document.querySelectorAll('[data-cr-chat-view]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
++ draw();
++ }));
++ draw();
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
++})();
ddiff --git a/server/components/registry.js b/server/components/registry.js
iindex c385c8a..b5f3b35 100644
---- a/server/components/registry.js
++++ b/server/components/registry.js
@@@ -24,6 +24,7 @@ const COMPONENTS = Object.freeze([
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' }),
define('thread-composer', 'Thread composer', 'A multiline message field with Options, attachments and send or stop.', [['field', 'message text'], ['options', 'model and attachment choices'], ['send', 'submit or stop']], { action: 'url', placeholder: 'string', options: 'action[]', attach: 'boolean', busy: 'boolean' }, ['empty', 'composing', 'uploading', 'busy'], ['color.bg.raised', 'color.text.primary', 'size.tap'], { keyboardAware: true, accessibility: { focus: 'Enter sends when the field permits; Shift+Enter adds a line; Options has a spoken label' } }, ['thread', 'thread.composer({ action, placeholder, attach })'], ['SurfaceContainerHigh', 'Use an OutlinedTextField, Options icon button and send or stop button; imePadding on the dock.'], 'Use TextEditor with an Options Menu and send or stop Button above the keyboard.', { placeholder: 'Ask anything', attach: true }),
++ define('chat', 'Chat', 'A conversation: messages as room lines or bubbles, each with its actions in one cluster at its corner, and the box that sends, replies and edits.', [['messages', 'room lines or bubbles, grouped by person, with their time'], ['actions', 'reply, edit, translate, copy: one cluster at a message\'s corner, on hover, focus or a tap'], ['edited', 'one quiet word after the words, the time as its title'], ['bar', '"Replying to …" or "Editing your message" above the box'], ['composer', 'the chat preset: attach, emoji, voice and a round send']], { messages: 'message[]', view: '"room" | "bubbles"', me: 'id', actions: 'id[] | (message, { mine }) => id[]', onAction: 'function' }, ['default', 'hover', 'acting', 'replying', 'editing', 'edited'], ['color.bg.raised', 'color.text.muted', 'color.accent', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Each action is a labelled button reached by Tab; Esc stops a reply or an edit' } }, ['messages', "OVMessages.render(list, { messages, view, me, actions, onAction }); OpenVibeComposer.mount(box, { preset: 'chat' }) with .reply() and .edit()"], ['SurfaceContainerHigh', 'A LazyColumn of messages; a long press opens the actions as a menu anchored at the message; the composer sits on the bottom bar with a reply or edit strip above it (imePadding).'], 'A List of messages with a contextMenu for the actions; the composer above the keyboard with a reply or edit strip.', { view: 'room', actions: ['reply', 'edit', 'copy'] }),
define('sheet', 'Sheet', 'A modal task surface: one heading, one close action and scrollable content.', [['handle', 'touch affordance'], ['header', 'title and close'], ['body', 'task controls']], { title: 'string', open: 'boolean', body: 'content' }, ['closed', 'opening', 'open', 'closing'], ['color.bg.raised', 'color.line.strong', 'radius.lg'], { keyboardAware: true, outsideTapCloses: true, scrollLock: true, accessibility: { focus: 'Trap focus while open; Escape closes and returns focus' } }, ['kit', 'kit.sheet({ id, title, body })'], ['SurfaceContainerHigh', 'Use ModalBottomSheet with a heading, close control and sheet state.'], 'Use .sheet with a NavigationStack and a toolbar close button.', { title: 'Add a site' }),
define('popover', 'Popover', 'A small anchored set of choices that closes on outside tap.', [['trigger', 'anchor'], ['choices', 'menu items']], { label: 'string', items: 'action[]', open: 'boolean' }, ['closed', 'open', 'selected'], ['color.bg.raised', 'color.line.strong', 'size.tap'], { keyboardAware: true, outsideTapCloses: true, accessibility: { focus: 'Arrow keys move choices; Escape returns focus to trigger' } }, ['kit', 'kit.more({ label, items })'], ['SurfaceContainerHigh', 'Use DropdownMenu anchored to the trigger.'], 'Use Menu attached to the trigger Button.', { label: 'More' }),
define('keyboard', 'Keyboard behavior', 'Shared focus, Escape and inset rules for controls near the screen edge.', [['focus', 'active input'], ['inset', 'visible keyboard space'], ['dismiss', 'return path']], { focused: 'boolean', returnFocus: 'string?' }, ['idle', 'keyboard-open', 'dismissed'], ['size.tap', 'space.md'], { keyboardAware: true, accessibility: { focus: 'Keep the focused control visible; dismiss without losing the place' } }, ['kit-web', 'Shared focus and form handling'], ['Surface', 'Use WindowInsets.ime and bringIntoViewRequester for focused fields.'], 'Use safeAreaInset and FocusState; scroll the focused field into view.', { focused: true }),
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex d6c2213..e96b151 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -505,6 +505,10 @@ const RULES = Object.freeze([
R("logs-keep-cause-and-effect", "data", "A log keeps each cause with what it caused.",
"Who did it (or what was said) leads a turn, and what happened sits indented under it, each outcome with its own icon and tint; a refused turn is marked as refused. A log is never a run of equal boxes where the reader pairs \"Ada presses\" with the line after it, and words every row already implies (\"(simulated)\" on a page that says it is a sample) are said once, above the log.",
'2026-11-12', 'The element preview\'s sample run showed six presses and their six results as twelve equal boxes, cut off by a scroll box.'),
++ R("message-actions-at-the-corner", "phone", "A message's actions are one cluster at its corner, never in its words.",
++ "Reply, edit, translate, copy and delete are icon buttons, each with its label, in one cluster that floats just above the message's right end: on hover or keyboard focus on a computer, on a tap of the message on a phone, where each is big enough for a thumb (44 × 40). Nothing about a message shows until asked for. Editing happens in the chat box, under an \"Editing your message\" bar (Enter saves, Esc puts back what was in the box), never in a browser prompt; replying shows \"Replying to …\" there. A changed message says \"edited\" once, quietly, right after its words, with the time as its title; never a line of its own. Every site's chat takes these from openvibe-shared (messages.js, ov-composer.js), so one fix reaches them all.",
++ '2026-11-12', 'The owner, with phone screenshots of a stream\'s chat: every one of your own messages carried a full-size "Edit" pill in its words, editing opened the browser\'s prompt box, and "edited · 06:49 PM" fell onto a line of its own ("The edit button needs improvement … so it never happens again").',
++ [{ label: 'The chat component', href: '/components/chat' }], ['openvibe-shared test: messages', 'openvibe-shared test: composer-v2-chrome', 'OpenVibe.Live test: chat-msg-actions']),
]);
// The register's order is the rule's number: a person can cite "rule 7" and a link can carry it.
ddiff --git a/server/views/component-registry.js b/server/views/component-registry.js
iindex 58efc54..ccf5056 100644
---- a/server/views/component-registry.js
++++ b/server/views/component-registry.js
@@@ -7,6 +7,11 @@ const { layout, esc } = require('../render/layout');
const { shippedSection, specimen, list: noteList } = require('./component-notes');
const link = (name) => `/components/${name}`;
++// A live demo built from openvibe-shared's own files loads them, as a site does.
++const PAGE_EXTRA = {
++ 'thread-composer': { shared: ['ov-thread.js'], sharedStyles: ['thread.css'] },
++ chat: { shared: ['messages.js', 'ov-composer.js'], sharedStyles: ['messages.css'], scripts: ['js/component-chat.js'] },
++};
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: () => specimen('menu'),
@@@ -14,6 +19,7 @@ const demo = {
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>`,
'thread-composer': () => thread.composer({ action: '#', method: 'get', placeholder: 'Ask anything', tools: kit.sheetButton({ sheet: 'cr-options', label: 'Options', icon: 'more', iconOnly: false }), attach: { name: 'files' }, id: 'cr-composer' }) + kit.sheet({ id: 'cr-options', title: 'Options', body: kit.seg({ name: 'cr-model', label: 'Model', options: [['auto', 'Auto'], ['fast', 'Fast']], value: 'auto' }) }),
++ chat: () => `<div class="cr-chat"><div class="cr-chat-views" role="group" aria-label="How messages look"><button type="button" class="ovk-btn ovk-btn--quiet" data-cr-chat-view="room" aria-pressed="true">Room lines</button><button type="button" class="ovk-btn ovk-btn--quiet" data-cr-chat-view="bubbles" aria-pressed="false">Bubbles</button></div><ol class="ovm-messages" id="cr-chat-list" aria-label="Demo conversation"></ol><div id="cr-chat-box"></div><p class="ds-fine">Hover a message (on a phone, tap it): reply, edit, translate and copy are one cluster at its corner. Edit puts the words in the box; Enter saves, Esc puts back what was there. The demo keeps its messages on this page.</p></div>`,
sheet: () => kit.sheetButton({ sheet: 'cr-sheet', label: 'Open sheet', icon: 'plus', iconOnly: false }) + kit.sheet({ id: 'cr-sheet', title: 'Add a site', body: `<p>Pick a site and theme.</p>${kit.seg({ name: 'cr-theme', label: 'Look', options: [['vibe', 'Vibe'], ['daylight', 'Daylight']], value: 'vibe' })}` }),
popover: () => kit.more({ label: 'More choices', items: [{ href: '/themes', label: 'Themes', icon: 'palette' }, { href: '/look', label: 'Your look', icon: 'user' }] }),
keyboard: () => `<label class="cr-field">A keyboard-aware field <input type="text" placeholder="Focus this field"></label><p class="ds-fine">Tab moves forward; Escape closes a menu or sheet and returns focus.</p>`,
@@@ -37,7 +43,7 @@ function registryLinks() {
const GROUPS = [
['Navigation', ['navbar', 'menu', 'you-home', 'dock', 'dock-tabs']],
-- ['Writing and choosing', ['thread-composer', 'sheet', 'popover', 'keyboard']],
++ ['Writing and choosing', ['chat', 'thread-composer', 'sheet', 'popover', 'keyboard']],
['Content', ['kit-row', 'kit-card', 'kit-section', 'empty-state']],
['Settings', ['switch-row', 'segmented-control']],
['Files and AI', ['upload-progress', 'ai-choice']],
@@@ -87,7 +93,7 @@ ${shippedSection(c.name)}
<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()}
<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'] : [] });
++ 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', ...(PAGE_EXTRA[c.name] || {}).scripts || []], sharedStyles: (PAGE_EXTRA[c.name] || {}).sharedStyles || [], shared: (PAGE_EXTRA[c.name] || {}).shared || [] });
}
module.exports = { componentPage, registryPage, registryLinks };
ddiff --git a/test/changes.test.js b/test/changes.test.js
iindex 8450e9c..b581107 100644
---- a/test/changes.test.js
++++ b/test/changes.test.js
@@@ -61,8 +61,17 @@ const { PUBLIC_DIR } = require('../server/assets');
assert.strictEqual((await t.get('/changes/specimen-player.mp4')).status, 200);
});
++ // /changes shows the newest twelve releases; each release keeps its whole entry on its own page. A Shared pin moves
++ // older ones past the fold (4.211 pushed 4.195–4.196; 4.217 pushed 4.200–4.205), so these read the whole archive.
++ const archive = async () => {
++ const list = (await t.get('/api/v1/changes?limit=500')).json();
++ const versions = (list.releases || list.changes || list.items || []).map((x) => x.version).filter(Boolean);
++ const pages = [await t.get('/changes')];
++ for (const v of versions) pages.push(await t.get(`/changes/${v}`));
++ return { text: pages.map((x) => x.text).join('\n'), versions };
++ };
await check('each release links the component pages it touches, and every link answers', async () => {
-- const r = await t.get('/changes');
++ const r = await archive();
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);
@@@ -72,7 +81,7 @@ const { PUBLIC_DIR } = require('../server/assets');
});
await check('the rule numbers are the register’s own, and each links to its rule', async () => {
-- const r = await t.get('/changes');
++ const r = await archive();
const { RULES } = require('../server/rules/register');
const rule = RULES.find((x) => x.id === 'button-becomes-its-tab');
assert.ok(rule && rule.n > 0, 'numbered');
@@@ -81,10 +90,9 @@ const { PUBLIC_DIR } = require('../server/assets');
});
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 r = await archive();
const paths = [...new Set([...r.text.matchAll(/\/changes\/(\d+\.\d+\.\d+)\/([a-z0-9-]+-(?:390|1280)-(?:dark|light)\.webp)/g)].map((m) => m[0]))];
-- // 24 plates (2026-11-11, was 40): /changes renders the newest twelve releases; the 4.211.0 pin
-- // pushed 4.195.0 and 4.196.0 past that fold, and each still shows its plates on its own page below.
++ // Across the whole archive (every release's page), not the newest twelve on /changes.
assert.ok(paths.length >= 24, `${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']) {
ddiff --git a/test/component-registry.test.js b/test/component-registry.test.js
iindex 63bb7cb..7cd51de 100644
---- a/test/component-registry.test.js
++++ b/test/component-registry.test.js
@@@ -47,6 +47,15 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(edit.definition.states.includes('editing'));
});
++ await check('the chat page is the shared chat, live: messages.js, ov-composer.js and the demo; rule 138 links to it', async () => {
++ const res = await t.get('/components/chat');
++ assert.strictEqual(res.status, 200);
++ const html = res.text;
++ ['messages.js', 'ov-composer.js', 'messages.css', 'component-chat.js'].forEach((f) => assert.ok(html.includes(f), `the page loads ${f}`));
++ assert.ok(html.includes('id="cr-chat-list"') && html.includes('id="cr-chat-box"'), 'the list and the box');
++ const rules = (await t.get('/rules')).text;
++ assert.ok(rules.includes('message-actions-at-the-corner') || rules.includes("A message's actions are one cluster at its corner") || rules.includes('A message&#39;s actions are one cluster at its corner'), 'rule 138 is on /rules');
++ });
await check('the API reads the stored registry and returns the example markup', async () => {
const all = (await t.get('/api/v1/components')).json();
assert.strictEqual(all.schema, 'design.component@1');