openvibers/openvibe.shared Commits

4.28.0: the room kit (rooms, kit.card, kit.rail, sc.room, motion) (#159)

OP OpenVibers committed 16 days ago · 8e24ad7703 · 15 files, +744 −10

* 4.28.0: the room kit: rooms.js (fourteen rooms, accents AA in both modes), kit.card (one card for every kind of thing), kit.rail (snap rows, arrows on pointers, menus never clipped), sc.room() (a room's front door in the shared order), motion.js (reveal, ticker, typed hints, end state server-written)

* search-loading test waits for the lazily loaded search box instead of 400 ms (the macOS runner was slower)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 936838f..e1e8d84 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,44 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.28.0 — 2026-10-28
++
++- **The room kit** (plan 2026-10-28 §3.3–§3.5, §3.11): what every new room's pages are built from.
++ - **`rooms.js`** (`openvibe-shared/rooms`, `window.OpenVibeRooms`): the fourteen rooms (Fun, Shoes, Tours, Today,
++ Meme, Domains, Casino, Exchange, Cam, Surf, Report, Website, Vision, Design) with:
++ - their neighbourhood, host, hero line (eight words at most), icon and motif;
++ - an accent every 25° of OKLCH hue: a dark-theme value that passes AA on `#0B0D12`, and a light-theme value
++ (text and filled buttons) that passes AA on white.
++
++ `rooms.style(id)` on a wrapper with the class `ov-room` gives `--room-accent`, `--room-strong`, `--room-fill` and
++ `--room-on`, in the right one for the theme's mode (`:root[data-theme-mode]`). An unknown room keeps the
++ theme's accent.
++ - **`kit.card()`**: one card for every kind of thing.
++ - Media in the type's shape (16:9 streams and runs, 1:1 memes, shoes and deals, 4:3 reports and themes,
++ 2:1 domains) with a badge (LIVE pulses) and a corner (a length or a price).
++ - A peek line on hover, and a muted preview loop that loads only while the card is hovered (computers only,
++ never under reduced motion).
++ - The title in two lines at most, then who, one count and when. A count of 0 or none is left out.
++ - A ⋯ menu: Save and Remind when given, and Share always (the system share sheet, else the link copied).
++ - The whole card is one link.
++ - **`kit.rail()`**: a row that snaps.
++ - About 1.2 cards show on a phone, bleeding to the screen's edge.
++ - Fixed widths on wider screens (`s` for square things, `m`, `l`).
++ - ‹ › buttons on devices with a pointer, each hidden at its end.
++ - A card's menu inside a rail is placed on the screen, so the scroller never clips it.
++ - **`sc.room()`**: a room's front door, in the order every room shares: the hero, the rails, the scenes, then
++ explore, for developers, questions and the rest of OpenVibe.
++ - The hero holds up to eight words, one box (with typed hints) or one Start, five chips at most, and the
++ live proof.
++ - Every live-proof number is real and is either live or dated; a 0 is left out.
++ - The room's motif sits beside the hero, and faint in the corner on phones. The default is the room's icon in
++ an aura of its accent; a room can bring its own.
++ - Feature scenes alternate sides and reveal as they scroll in.
++ - **`motion.js`**: reveal on scroll, a number ticker and a typed placeholder.
++ - The server always writes the end state, so a page is complete without the script, under reduced motion
++ (the OS setting or the account's) and for crawlers.
++ - The kit's icons gain `chart`, `swap`, `wave`, `shoe` and `palette`.
++
## 4.27.0 — 2026-10-28
- **Notifications 2.0** (`notification-ui.js`). The owner's screenshot on 10-28: three red "Alert:
ddiff --git a/README.md b/README.md
iindex 3ddc282..78c234b 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.27.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.28.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex b0b7650..09c88d4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.27.0",
++ "version": "4.28.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/home-pages.md b/docs/home-pages.md
iindex e594cd3..edb3493 100644
---- a/docs/home-pages.md
++++ b/docs/home-pages.md
@@@ -47,6 +47,36 @@ ${sc.cta({ title: 'Ready?', actions: [{ label: 'Start', href: '/start', primary:
| `nextSteps` | What to do next on other products after finishing something | |
| `cta` | The closing call to action | |
++## A room's front door: `sc.room()` (4.28.0)
++
++```js
++sc.room({ room: 'surf',
++ hero: { title, lede, search: { action, name, placeholder, hints }, start: { href, label }, chips, motif },
++ proof: [{ value, label, live: true } | { value, label, asOf }],
++ rails: [{ id, title, more, items: [kit.card(…)], size }],
++ scenes: [{ title, text, html, action }],
++ explore, dev: { title, lede, samples, links }, faq, network, html })
++```
++
++It renders the parts in the order every room shares (plan 2026-10-28 §3.3), wrapped in the room's accent:
++
++| # | Part | What it holds |
++| --- | --- | --- |
++| 1 | Hero | Eight words at most; one box (typed hints) or one Start; five chips at most; the motif beside it (faint in the corner on phones) |
++| 2 | Live proof | Real numbers, each live or dated; a 0 is left out |
++| 3 | Rails | Right now, For you |
++| 4 | Feature scenes | The room's own components in a demo state, alternating sides, revealed as they scroll in |
++| 5 | Explore | Where to go in the room |
++| 6 | For developers | A snippet, with OpenAPI and MCP links |
++| 7 | Questions, the rest of OpenVibe | |
++
++The page loads:
++- `kit.css` and `showcase.css`;
++- `kit-web.js` for rails, menus and share;
++- `motion.js` for the ticker, the typed hints and the reveal.
++
++The server always writes the finished state, so the page is whole without scripts.
++
## Rules the kit enforces
- **Escaping:** every string is escaped. Only a field named `html` passes markup through, and it must be the caller's own.
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 2338d49..735388b 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -196,6 +196,53 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.
`search`, `gear`, `members`, `invite`, `chat`, `bell`, `home`, `spark`, `live`, `video`, `image`, `tag`, `coin`,
`trophy`, `map`, `code`, `robot` and `game`.
++### Cards and rails (4.28.0)
++
++```js
++kit.card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar,
++ count: [1832, 'views'], when, level, save: { href }, remind: { href }, share, more: [items] })
++kit.rail({ id, title, more: { href, label }, items: [kit.card(…), …], size: 's' | 'm' | 'l' })
++```
++
++One card for every kind of thing (plan 2026-10-28 §3.4):
++
++| Part | What it does |
++| --- | --- |
++| Media | In the type's shape (`kit.CARD_RATIO`): 16:9 streams, clips, runs, maps, cams, events; 1:1 memes, shoes, listings, deals; 4:3 reports, themes, games; 2:1 domains. `ratio` overrides it. Without a picture, the type's icon on a wash of the room's accent |
++| Badge | Top left, one word: NEW, −24 %, WR. `live: true` draws LIVE with a pulse |
++| Corner | Bottom right: a length or a price |
++| Peek | One line over the media on hover or focus |
++| Preview | `video`: a muted loop while the card is hovered. Computers only, never under reduced motion, and the file loads only then |
++| Title | Two lines at most. The card is one link: the title's, stretched over it |
++| Meta | Who (with their picture or letters), one count, when. A count of 0 or none is left out, never shown as 0 |
++| ⋯ | Save and Remind when given, and Share always (the system share sheet, else the link copied, said in the item) |
++
++A rail scrolls sideways and snaps:
++
++| Screen | How it looks |
++| --- | --- |
++| Phone | About 1.2 cards show, and the row bleeds to the screen's edge (the page keeps a 16 px gutter) |
++| Wider | 190 px (`s`), 280 px (`m`) or 400 px (`l`) cards |
++| Pointer | ‹ › buttons, each hidden at its end |
++
++A card's menu inside a rail is placed on the screen, so the scroller never clips it. An empty rail is `''`.
++
++### Rooms (4.28.0)
++
++`require('openvibe-shared/rooms')` (`window.OpenVibeRooms`) holds the fourteen rooms. Each has a neighbourhood, a host,
++a hero line, an icon, a motif and an accent: a dark value (AA on `#0B0D12`) and a light value (AA on white).
++
++Put `rooms.style(id)` on the room's wrapper with the class `ov-room`. Its pieces then use these, in the right one for
++the theme's mode:
++
++| Property | Use |
++| --- | --- |
++| `--room-accent` | Icons, outlines, motifs |
++| `--room-strong` | Text on the page |
++| `--room-fill`, `--room-on` | A filled button and its text |
++
++`sc.room()` does this for a front door.
++
## The navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
ddiff --git a/kit-web.js b/kit-web.js
iindex ad5f489..55d7278 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -11,6 +11,10 @@
* sliders input[type=range][data-ovk-unit] say their value
* sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it
* tabs a sideways-scrolling tab strip keeps the open tab in view
++ * rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
++ * share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
++ * card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
++ * under reduced motion; the file loads only then)
*
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
@@@ -39,7 +43,22 @@
// Menus: one open at a time; a click elsewhere or Escape closes them.
all('details.ovk-more').forEach((d) => {
if (!once(d, 'ovkMore')) return;
-- d.addEventListener('toggle', () => { if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; }); });
++ d.addEventListener('toggle', () => {
++ if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; });
++ // Inside a rail (a sideways scroller clips anything that hangs out of it) the menu is placed on the screen.
++ const pop = d.querySelector('.ovk-pop');
++ if (!pop || !d.closest('.ovk-rail-track')) return;
++ pop.classList.toggle('is-fixed', d.open);
++ if (!d.open) { pop.style.left = pop.style.top = ''; return; }
++ const r = d.getBoundingClientRect();
++ const w = pop.offsetWidth, h = pop.offsetHeight;
++ pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`;
++ pop.style.top = `${r.bottom + 4 + h > innerHeight - 8 ? Math.max(8, r.top - h - 4) : r.bottom + 4}px`;
++ const shut = () => { d.open = false; };
++ const track = d.closest('.ovk-rail-track');
++ track.addEventListener('scroll', shut, { once: true, passive: true });
++ window.addEventListener('scroll', shut, { once: true, passive: true });
++ });
});
if (!document.documentElement.dataset.ovkDoc) {
document.documentElement.dataset.ovkDoc = '1';
@@@ -99,6 +118,54 @@
});
// Tabs that scroll sideways keep the open one in view.
all('.ovk-tabs [aria-current="page"]').forEach((a) => { if (once(a, 'ovkTab') && a.scrollIntoView) a.scrollIntoView({ block: 'nearest', inline: 'center' }); });
++ // Rails: ‹ › move a page of cards where there is a pointer to click them; each hides at its end.
++ const fine = typeof matchMedia === 'function' && matchMedia('(hover: hover) and (pointer: fine)').matches;
++ const still = () => (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
++ all('.ovk-rail').forEach((r) => {
++ if (!once(r, 'ovkRail')) return;
++ const track = r.querySelector('.ovk-rail-track');
++ const prev = r.querySelector('.ovk-rail-prev');
++ const next = r.querySelector('.ovk-rail-next');
++ if (!track || !prev || !next || !fine) return;
++ const update = () => {
++ const max = track.scrollWidth - track.clientWidth;
++ prev.hidden = track.scrollLeft <= 4;
++ next.hidden = track.scrollLeft >= max - 4;
++ };
++ const go = (dir) => track.scrollBy({ left: dir * Math.max(track.clientWidth * 0.85, 200), behavior: still() ? 'auto' : 'smooth' });
++ prev.addEventListener('click', () => go(-1));
++ next.addEventListener('click', () => go(1));
++ track.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
++ if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update);
++ update();
++ });
++ // Share: the system sheet where there is one, else the link copied, said in the item itself.
++ all('[data-ovk-share]').forEach((a) => {
++ if (!once(a, 'ovkShare')) return;
++ a.addEventListener('click', (e) => {
++ e.preventDefault();
++ const link = new URL(a.getAttribute('href'), location.href).href;
++ const title = a.getAttribute('data-ovk-share') || document.title;
++ const done = () => { const d = a.closest('details'); if (d) d.open = false; };
++ if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; }
++ if (!navigator.clipboard) { location.href = link; return; }
++ navigator.clipboard.writeText(link).then(() => {
++ const span = a.querySelector('span');
++ const was = span ? span.textContent : '';
++ if (span) span.textContent = 'Link copied';
++ setTimeout(() => { if (span) span.textContent = was; done(); }, 1200);
++ }, () => {});
++ });
++ });
++ // Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion.
++ if (fine) all('.ovk-card').forEach((c) => {
++ const v = c.querySelector('video.ovk-card-video[data-src]');
++ if (!v || !once(c, 'ovkPeek')) return;
++ const on = () => { if (still()) return; if (!v.src) v.src = v.getAttribute('data-src'); c.classList.add('is-playing'); const p = v.play(); if (p && p.catch) p.catch(() => {}); };
++ const off = () => { c.classList.remove('is-playing'); try { v.pause(); } catch (e) { /* not started */ } };
++ c.addEventListener('mouseenter', on); c.addEventListener('mouseleave', off);
++ c.addEventListener('focusin', on); c.addEventListener('focusout', off);
++ });
}
ddiff --git a/kit.css b/kit.css
iindex f24d256..a78e02b 100644
---- a/kit.css
++++ b/kit.css
@@@ -233,13 +233,80 @@ div.ovk-set-row { cursor: default; }
.ovk-sheet-body { padding: 14px 16px calc(16px + env(safe-area-inset-bottom, 0px)); overflow-y: auto; }
/* ── Phones (< 600 px): bottom sheets, full-width forms ── */
++/* ── Rooms (rooms.js): the room's accent in the theme's mode. rooms.style(id) sets --room-dark/--room-light on .ov-room;
++ * without them the theme's accent stays. --room-accent: icons, outlines, motifs; --room-strong: text on the page;
++ * --room-fill and --room-on: a filled button and its text (dark text on a dark theme's bright fill, white on light). ── */
++.ov-room { --room-accent: var(--ovk-accent); --room-strong: var(--ovk-accent); --room-fill: var(--ovk-fill); --room-on: var(--ovk-on-fill); }
++.ov-room[style*="--room-dark"] { --room-accent: var(--room-dark); --room-strong: var(--room-dark); --room-fill: var(--room-dark); --room-on: #0b0d12; }
++:root[data-theme-mode="light"] .ov-room[style*="--room-light"] { --room-accent: var(--room-light); --room-strong: var(--room-light); --room-fill: var(--room-light); --room-on: #fff; }
++
++/* ── Cards (kit.card): one card for every kind of thing ── */
++.ovk-card { position: relative; display: flex; flex-direction: column; gap: 10px; min-width: 0; color: var(--ovk-text); }
++.ovk-card-media { position: relative; display: block; aspect-ratio: var(--ovk-ar, 16/9); border-radius: var(--ovk-radius); overflow: hidden; background: var(--ovk-panel-2); isolation: isolate; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovk-line) 70%, transparent) inset; }
++.ovk-card-media > img, .ovk-card-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
++.ovk-card-media > img { transition: transform .5s cubic-bezier(.2, .7, .2, 1); }
++.ovk-card-video { opacity: 0; transition: opacity .25s; }
++.ovk-card.is-playing .ovk-card-video { opacity: 1; }
++.ovk-card-noimg { position: absolute; inset: 0; display: grid; place-items: center; color: var(--room-accent, var(--ovk-accent)); background: radial-gradient(120% 90% at 28% 18%, color-mix(in srgb, var(--room-accent, var(--ovk-accent)) 24%, transparent), transparent 72%), var(--ovk-panel-2); }
++.ovk-card-noimg .ovk-i { width: 30%; max-width: 60px; height: auto; aspect-ratio: 1; stroke-width: 1.5; }
++.ovk-card-badge { position: absolute; top: 8px; left: 8px; z-index: 1; display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 6px; background: rgba(0, 0, 0, .74); color: #fff; font: 800 11.5px/1.5 var(--ovk-font); letter-spacing: .04em; }
++.ovk-card-badge.is-live { background: #d4202b; }
++.ovk-card-badge.is-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: ovk-pulse 1.6s ease-in-out infinite; }
++@keyframes ovk-pulse { 50% { opacity: .35; } }
++.ovk-card-corner { position: absolute; right: 8px; bottom: 8px; z-index: 1; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .78); color: #fff; font: 700 12px/1.4 var(--ovk-font); font-variant-numeric: tabular-nums; transition: opacity .25s; }
++.ovk-card-peek { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; padding: 34px 12px 10px; background: linear-gradient(transparent, rgba(0, 0, 0, .88)); color: #fff; font-size: 12.5px; line-height: 1.4; opacity: 0; transform: translateY(8px); transition: opacity .3s, transform .35s cubic-bezier(.2, .7, .2, 1); pointer-events: none; }
++.ovk-card-body { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
++.ovk-card-body > .ovk-mark { flex: none; }
++.ovk-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
++.ovk-card-title { margin: 0; font-size: 15px; font-weight: 700; line-height: 1.3; letter-spacing: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
++.ovk-card-link { color: inherit; text-decoration: none; transition: color .2s; }
++.ovk-card-link::after { content: ""; position: absolute; inset: 0; z-index: 1; border-radius: var(--ovk-radius); }
++.ovk-card-link:focus-visible { outline: none; }
++.ovk-card-link:focus-visible::after { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 3px; }
++.ovk-card-meta { margin: 0; display: flex; flex-wrap: wrap; color: var(--ovk-muted); font-size: 13px; line-height: 1.45; min-width: 0; }
++.ovk-card-meta > * + *::before { content: "·"; margin: 0 6px; }
++.ovk-card-who { font-weight: 600; color: var(--text-secondary, var(--ovk-muted)); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovk-card .ovk-more { position: relative; z-index: 2; margin: -6px -8px 0 0; }
++@media (hover: hover) {
++ .ovk-card:hover .ovk-card-media > img, .ovk-card:focus-within .ovk-card-media > img { transform: scale(1.05); }
++ .ovk-card:hover .ovk-card-peek, .ovk-card:focus-within .ovk-card-peek { opacity: 1; transform: none; }
++ .ovk-card:hover .ovk-card-corner:has(~ .ovk-card-peek), .ovk-card:focus-within .ovk-card-corner:has(~ .ovk-card-peek) { opacity: 0; }
++ .ovk-card:hover .ovk-card-link { color: var(--room-strong, var(--ovk-accent)); }
++ .ovk-card .ovk-more:not([open]) > summary { opacity: 0; transition: opacity .15s; }
++ .ovk-card:hover .ovk-more > summary, .ovk-card:focus-within .ovk-more > summary { opacity: 1; }
++}
++
++/* ── Rails (kit.rail): a row that scrolls sideways and snaps ── */
++.ovk-rail { margin: 26px 0 0; min-width: 0; --ovk-rail-bleed: 0px; }
++.ovk-rail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 12px; }
++.ovk-rail-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; }
++.ovk-rail-more { display: inline-flex; align-items: center; gap: 2px; min-height: 32px; color: var(--room-strong, var(--ovk-accent)); font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }
++.ovk-rail-more:hover { text-decoration: underline; }
++.ovk-rail-wrap { position: relative; }
++.ovk-rail-track { list-style: none; margin: 0 calc(-1 * var(--ovk-rail-bleed)); padding: 2px var(--ovk-rail-bleed) 10px; display: grid; grid-auto-flow: column; grid-auto-columns: var(--ovk-rail-w); gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--ovk-rail-bleed); overscroll-behavior-x: contain; scrollbar-width: none; }
++.ovk-rail-track::-webkit-scrollbar { display: none; }
++.ovk-rail-track > li { scroll-snap-align: start; min-width: 0; }
++.ovk-rail--m { --ovk-rail-w: min(80%, 320px); }
++.ovk-rail--s { --ovk-rail-w: min(44%, 200px); }
++.ovk-rail--l { --ovk-rail-w: min(86%, 440px); }
++.ovk-rail-btn { position: absolute; top: 34%; z-index: 3; width: 40px; height: 40px; margin-top: -20px; border-radius: 50%; display: grid; place-items: center; border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); box-shadow: 0 6px 18px rgba(0, 0, 0, .3); cursor: pointer; font-size: 18px; }
++.ovk-rail-btn:hover { border-color: var(--room-accent, var(--ovk-accent)); color: var(--room-strong, var(--ovk-accent)); }
++.ovk-rail-btn:focus-visible { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 2px; }
++.ovk-rail-btn[hidden] { display: none; }
++.ovk-rail-prev { left: 6px; }
++.ovk-rail-next { right: 6px; }
++.ovk-pop.is-fixed { position: fixed; right: auto; top: auto; }
++
@media (max-width: 599px) {
++ .ovk-rail { --ovk-rail-bleed: 16px; }
++ .ovk-rail-btn { display: none; }
.ovk-sheet { margin: auto 0 0; width: 100vw; border-radius: 18px 18px 0 0; border-bottom: 0; }
.ovk-row > a, .ovk-row--static { padding-left: 12px; }
.ovk-head-tools { gap: 0; }
}
/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */
@media (min-width: 600px) {
++ .ovk-rail--m { --ovk-rail-w: 280px; } .ovk-rail--s { --ovk-rail-w: 190px; } .ovk-rail--l { --ovk-rail-w: 400px; }
.ovk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); margin-left: 0; margin-right: 0; padding: 0; overflow: visible; }
.ovk-save .ovk-btn { width: auto; min-width: 200px; }
.ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
@@@ -250,5 +317,6 @@ div.ovk-set-row { cursor: default; }
.ovk-split { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
.ovk-split > .ovk-side { position: sticky; top: calc(var(--ovk-top) + 12px); }
}
--@media (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn { transition: none; } }
++@media (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn, .ovk-card-media > img, .ovk-card-peek, .ovk-card-video { transition: none; } .ovk-card-badge.is-live::before { animation: none; } }
++html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none; }
@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
ddiff --git a/kit.js b/kit.js
iindex 1996f9f..e77116a 100644
---- a/kit.js
++++ b/kit.js
@@@ -94,7 +94,11 @@
cloud: '<path d="M7 18h10a4 4 0 0 0 .5-8A6 6 0 0 0 6 9.5 4.3 4.3 0 0 0 7 18Z"/>', server: '<rect x="4" y="4" width="16" height="7" rx="2"/><rect x="4" y="13" width="16" height="7" rx="2"/><path d="M8 7.5h.01M8 16.5h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
moon: '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/>', grid: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>',
-- list: '<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',
++ list: '<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/>',
++ chart: '<path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 5-6"/>', swap: '<path d="M4 8h14l-3.5-3.5M20 16H6l3.5 3.5"/>',
++ wave: '<path d="M2 16c2.5 0 2.5-2 5-2s2.5 2 5 2 2.5-2 5-2 2.5 2 5 2"/><path d="M5.5 11.5A7 7 0 0 1 18 8.6c-2.3-.5-4.2.4-4.8 2.4"/>',
++ shoe: '<path d="M2.5 17v-5.5l4-.5 3-4 3.2 1.4-.9 2.6 4 2.2 4.7 1a2 2 0 0 1 1.5 1.9V17Z"/><path d="M2.5 20h19"/>',
++ palette: '<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7.3" r="1.2"/><circle cx="14.5" cy="7.3" r="1.2"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',
};
const ICONS = Object.freeze(Object.keys(P));
/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
@@@ -227,6 +231,7 @@
const inner = `${it.icon ? icon(it.icon) : ''}<span>${esc(it.label)}</span>`;
const cls = `ovk-pop-item${it.danger ? ' ovk-pop-item--danger' : ''}`;
if (it.action) return `<form method="post" action="${esc(url(it.action))}"${it.confirm ? ` data-ovk-confirm="${esc(it.confirm)}"` : ''}>${Object.keys(it.hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(it.hidden[k])}">`).join('')}<button type="submit" class="${cls}" role="menuitem">${inner}</button></form>`;
++ if (it.share) return `<a class="${cls}" role="menuitem" href="${esc(url(it.href))}" data-ovk-share="${esc(it.title || '')}">${inner}</a>`;
return `<a class="${cls}" role="menuitem" href="${esc(url(it.href))}">${inner}</a>`;
};
return `<details class="ovk-more"><summary class="ovk-icon-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon(ic)}</summary><div class="ovk-pop" role="menu">${items.filter(Boolean).map(item).join('')}</div></details>`;
@@@ -306,8 +311,75 @@
// markup with these builders loads both and calls enhance() on what it added. A no-op on the server.
const enhance = (rootEl) => (root.OpenVibeKitWeb ? root.OpenVibeKitWeb.enhance(rootEl) : undefined);
++ // ── Cards and rails (plan 2026-10-28 §3.4): one card for every kind of thing, in rows that snap ──
++ // The media's shape by what the card shows; `ratio` overrides it.
++ const CARD_RATIO = {
++ stream: '16/9', clip: '16/9', vod: '16/9', video: '16/9', run: '16/9', map: '16/9', cam: '16/9', tour: '16/9', trip: '16/9',
++ event: '16/9', brief: '16/9', post: '16/9', meme: '1/1', shoe: '1/1', listing: '1/1', deal: '1/1', person: '1/1',
++ toy: '4/3', game: '4/3', simulator: '4/3', report: '4/3', dashboard: '4/3', theme: '4/3', kit: '4/3', benchmark: '4/3', site: '4/3', domain: '2/1',
++ };
++ // What a card without a picture draws in its media, by type.
++ const CARD_ICON = { stream: 'live', clip: 'play', vod: 'play', video: 'play', run: 'wave', map: 'map', cam: 'video', tour: 'map', trip: 'map',
++ event: 'calendar', brief: 'calendar', post: 'file', meme: 'image', shoe: 'shoe', listing: 'tag', deal: 'tag', person: 'user', toy: 'spark',
++ game: 'game', simulator: 'game', report: 'chart', dashboard: 'chart', theme: 'palette', kit: 'palette', benchmark: 'chart', site: 'computer', domain: 'globe' };
++ const media = (u) => /^(https:\/\/|\/(?!\/))/.test(String(u || '').trim());
++ const fmtCount = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '')}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k` : String(n));
++ /**
++ * card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar, count: [n, 'watching'],
++ * when, level, save, remind, share, more: [items] }) — one thing, the same anatomy everywhere:
++ * - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a
++ * corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted
++ * loop played while it is hovered (computers only, never under reduced motion, loaded only then)
++ * - the title (two lines at most), then who (with their picture or letters), one count and when; a count of 0 or
++ * none is left out, never shown as 0
++ * - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else
++ * the link copied) and any `more` items (kit.more's)
++ * The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3).
++ */
++ function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '',
++ peek = '', who = '', avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true, more: extra = null } = {}) {
++ const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9';
++ const link = url(href);
++ const pic = media(image)
++ ? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`
++ : `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
++ const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
++ const tag = live ? '<span class="ovk-card-badge is-live">LIVE</span>' : bd ? `<span class="ovk-card-badge">${esc(bd)}</span>` : '';
++ const n = Array.isArray(count) ? Number(count[0]) : Number(count);
++ const counted = Number.isFinite(n) && n > 0 ? `<span>${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}</span>` : '';
++ const t = at != null && at !== '' ? when(at) : null;
++ const meta = [who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');
++ const items = [
++ save ? { label: 'Save', icon: 'star', ...save } : null,
++ remind ? { label: 'Remind me', icon: 'bell', ...remind } : null,
++ share ? { label: 'Share', icon: 'open', href: link, share: true, title } : null,
++ ].concat(extra || []).filter(Boolean);
++ const h = Math.min(6, Math.max(2, Number(level) || 3));
++ return `<article class="ovk-card${live ? ' is-live' : ''}${type ? ` ovk-card--${esc(type)}` : ''}" style="--ovk-ar:${ar}">`
++ + `<div class="ovk-card-media">${pic}${loop}${tag}${corner ? `<span class="ovk-card-corner">${esc(corner)}</span>` : ''}${peek ? `<span class="ovk-card-peek">${esc(peek)}</span>` : ''}</div>`
++ + `<div class="ovk-card-body">${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}<div class="ovk-card-text">`
++ + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`
++ + `${items.length ? more({ label: `More: ${title}`, items }) : ''}</div></article>`;
++ }
++ /**
++ * rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls
++ * sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance).
++ * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. Empty → ''.
++ */
++ function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '' } = {}) {
++ const list = (items || []).filter(Boolean);
++ if (!list.length) return '';
++ const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : '';
++ const head = title || m ? `<div class="ovk-rail-head">${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${m && m.href ? `<a class="ovk-rail-more" href="${esc(url(m.href))}">${esc(m.label || 'See all')}${icon('chevron')}</a>` : ''}</div>` : '';
++ const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`;
++ return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`
++ + `<div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list">${list.map((x) => `<li>${x}</li>`).join('')}</ul>`
++ + `<button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden>${icon('back')}</button>`
++ + `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
++ }
++
return {
-- esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot,
++ esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 1,
ddiff --git a/motion.js b/motion.js
nnew file mode 100644
iindex 0000000..ba5aa7f
---- /dev/null
++++ b/motion.js
@@@ -0,0 +1,69 @@
++/**
++ * openvibe-shared/motion.js — the network's motion, small and opt-in (plan 2026-10-28 §3.11): a page marks what moves and
++ * this makes it move, once, when it is seen. The server always renders the finished state (the real number, the whole
++ * scene, the plain placeholder), so a page is complete without this file, under reduced motion (the OS setting or the
++ * account's, html[data-ov-motion=reduced]) and for crawlers.
++ *
++ * [data-ov-reveal] fades and rises in the first time it scrolls into view (html.ov-motion hides it until then)
++ * [data-ov-ticker="1204"] counts up to its number when seen (the text the server wrote is the end state)
++ * input[data-ov-typed="a|b|c"] types each hint into the placeholder in turn, erases it, moves on; stops for good
++ * once the box is focused or has text
++ *
++ * Runs by itself when it loads; OpenVibeMotion.enhance(container) again for markup added later. Idempotent.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined') return;
++ const html = document.documentElement;
++ const still = () => (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) || html.getAttribute('data-ov-motion') === 'reduced';
++ const seen = typeof IntersectionObserver === 'function'
++ ? (el, fn, margin) => { const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); fn(e.target); } }), { rootMargin: margin || '0px 0px -8% 0px' }); io.observe(el); }
++ : (el, fn) => fn(el);
++
++ function tick(el) {
++ const end = Number(el.getAttribute('data-ov-ticker'));
++ const text = el.textContent;
++ if (!Number.isFinite(end) || end <= 0 || still()) return;
++ const t0 = performance.now(), dur = Math.min(1400, 500 + Math.log10(end + 1) * 220);
++ const step = (t) => {
++ const k = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - k, 3);
++ el.textContent = k < 1 ? Math.round(end * e).toLocaleString('en-US') : text;
++ if (k < 1) requestAnimationFrame(step);
++ };
++ requestAnimationFrame(step);
++ }
++
++ function typed(input) {
++ const hints = String(input.getAttribute('data-ov-typed') || '').split('|').map((s) => s.trim()).filter(Boolean);
++ const base = input.getAttribute('placeholder') || '';
++ if (!hints.length || still()) return;
++ let i = 0, n = 0, dir = 1, timer = 0, stopped = false;
++ const stop = () => { stopped = true; clearTimeout(timer); input.setAttribute('placeholder', base); };
++ input.addEventListener('focus', stop, { once: true });
++ input.addEventListener('input', stop, { once: true });
++ const run = () => {
++ if (stopped || input.value) return stop();
++ const h = hints[i];
++ n += dir;
++ input.setAttribute('placeholder', h.slice(0, n));
++ let wait = dir > 0 ? 45 + Math.random() * 40 : 22;
++ if (dir > 0 && n >= h.length) { dir = -1; wait = 1700; } else if (dir < 0 && n <= 0) { dir = 1; i = (i + 1) % hints.length; wait = 380; }
++ timer = setTimeout(run, wait);
++ };
++ timer = setTimeout(run, 900);
++ }
++
++ function enhance(scope) {
++ const box = scope || document;
++ const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel));
++ const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
++ all('[data-ov-reveal]').forEach((el) => { if (once(el, 'ovReveal')) { if (still()) el.classList.add('is-in'); else seen(el, (x) => x.classList.add('is-in')); } });
++ all('[data-ov-ticker]').forEach((el) => { if (once(el, 'ovTicker')) seen(el, tick, '0px'); });
++ all('input[data-ov-typed]').forEach((el) => { if (once(el, 'ovTyped')) typed(el); });
++ }
++
++ html.classList.add('ov-motion');
++ root.OpenVibeMotion = { enhance };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document));
++ else enhance(document);
++})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/package.json b/package.json
iindex 91de726..8e26a4c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.27.0",
++ "version": "4.28.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -79,7 +79,9 @@
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
"./route-transition": "./route-transition.js",
-- "./boost": "./boost.js"
++ "./boost": "./boost.js",
++ "./rooms": "./rooms.js",
++ "./motion": "./motion.js"
},
"files": [
"*.js",
ddiff --git a/rooms.js b/rooms.js
nnew file mode 100644
iindex 0000000..892154b
---- /dev/null
++++ b/rooms.js
@@@ -0,0 +1,62 @@
++/**
++ * openvibe-shared/rooms — OpenVibe's rooms (plan 2026-10-28 §3.1, §3.5): one table every site reads for a room's
++ * neighbourhood, its accent, its hero line and its motif, so a room looks like itself on every page that shows it.
++ *
++ * The accents sit every 25° of OKLCH hue. Each `dark` value passes AA against #0B0D12 (text, icons and fills on dark
++ * themes, with dark text on the fill); each `light` value passes AA against white (text and filled buttons on light
++ * themes, with white text). Put rooms.style(id) on the room's wrapper with the class ov-room: room.css then gives
++ * --room-accent (the right one for the theme's mode), --room-strong (text on the page) and --room-on (text on a fill).
++ *
++ * const rooms = require('openvibe-shared/rooms');
++ * rooms.get('surf') → { id, name, host, hood, hue, dark, light, line, motif, icon }
++ * rooms.style('surf') → '--room-dark:#00BEF6;--room-light:#0072AB'
++ * rooms.HOODS → { streams: 'Streams & places', … } (the menu's neighbourhoods; Network's frame is the authority)
++ * In the browser: window.OpenVibeRooms, the same.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeRooms = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const HOODS = Object.freeze({
++ streams: 'Streams & places', games: 'Games & laughs', answers: 'News & answers',
++ make: 'Make & build', shop: 'Shop & swap', people: 'People & you', build: 'For developers',
++ });
++
++ // id, name in menus, host, neighbourhood, OKLCH hue, dark accent, light accent, hero line (≤ 8 words), kit icon, motif
++ const ROWS = [
++ ['fun', 'Fun', 'openvibe.fun', 'games', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],
++ ['shoes', 'Shoes', 'open.shoes', 'shop', 25, '#FF7E76', '#B02A2D', 'Every shoe, every market, the right price', 'shoe', 'a shoe drawn as one line, price tags from several stores sliding into one all-in price'],
++ ['tours', 'Tours', 'openvibe.tours', 'streams', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],
++ ['today', 'Today', 'openvibe.today', 'answers', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],
++ ['meme', 'Meme', 'openvibe.meme', 'games', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],
++ ['domains', 'Domains', 'openvibe.domains', 'make', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],
++ ['casino', 'Casino', 'openvibe.casino', 'games', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],
++ ['exchange', 'Exchange', 'openvibe.exchange', 'shop', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],
++ ['cam', 'Cam', 'openvibe.cam', 'streams', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],
++ ['surf', 'Surf', 'openvibe.surf', 'games', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],
++ ['report', 'Report', 'openvibe.report', 'answers', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],
++ ['website', 'Website', 'openvibe.website', 'make', 275, '#8EA0FF', '#4B53C2', 'Serious sites, no login needed', 'computer', 'a browser frame assembling a real site from blocks while a cursor types the prompt'],
++ ['vision', 'Vision', 'openvibe.vision', 'answers', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],
++ ['design', 'Design', 'openvibe.design', 'make', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],
++ ];
++ const ROOMS = Object.freeze(Object.fromEntries(ROWS.map(([id, name, host, hood, hue, dark, light, line, icon, motif]) =>
++ [id, Object.freeze({ id, name, host, hood, hue, dark, light, line, icon, motif })])));
++ const IDS = Object.freeze(ROWS.map((r) => r[0]));
++
++ const byHost = new Map(ROWS.map((r) => [r[2], r[0]]));
++ /** A room by id or host ('surf', 'openvibe.surf', 'www.open.shoes'); null when it is not a room. */
++ function get(key) {
++ const k = String(key || '').toLowerCase().replace(/^www\./, '');
++ return ROOMS[k] || ROOMS[byHost.get(k)] || null;
++ }
++ /** The custom properties a room's wrapper carries (class ov-room); '' for an unknown room (the theme's accent stays). */
++ function style(key) {
++ const r = get(key);
++ return r ? `--room-dark:${r.dark};--room-light:${r.light}` : '';
++ }
++
++ return { HOODS, ROOMS, IDS, get, style, VERSION: 1 };
++});
ddiff --git a/showcase.css b/showcase.css
iindex 2cf407a..e6c2a49 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -1,4 +1,61 @@
/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */
++/* Rooms (4.28.0): sc.room() — a room's hero, live proof, motif and scenes, in the room's accent (.ov-room in kit.css:
++ --room-accent, --room-strong, --room-fill, --room-on). */
++.sc-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
++:where(.sc-room) .ovk-i{width:1.15em;height:1.15em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
++.sc-room-hero{position:relative;isolation:isolate;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,.8fr);gap:clamp(20px,4vw,48px);align-items:center;padding:clamp(20px,5vw,56px) 0 clamp(4px,2vw,16px)}
++.sc-room-hero h1{margin:0;font-size:clamp(2.05rem,5.4vw,3.5rem);font-weight:800;letter-spacing:-.035em;line-height:1.04;text-wrap:balance}
++.sc-room-lede{margin:14px 0 0;max-width:560px;font-size:clamp(1rem,1.6vw,1.13rem);line-height:1.55;color:var(--text-secondary,#96a7c2)}
++.sc-room-box{display:flex;align-items:center;margin-top:22px;max-width:560px;border-radius:999px;background:var(--bg-card,#0f1729);border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 45%,var(--border,#1f2d47));box-shadow:0 12px 32px -14px color-mix(in srgb,var(--room-accent,#3b82f6) 55%,transparent);transition:border-color .2s,box-shadow .2s}
++.sc-room-box:focus-within{border-color:var(--room-accent,#3b82f6);box-shadow:0 0 0 4px color-mix(in srgb,var(--room-accent,#3b82f6) 22%,transparent),0 12px 32px -14px color-mix(in srgb,var(--room-accent,#3b82f6) 60%,transparent)}
++.sc-room-box input{flex:1;min-width:0;height:56px;padding:0 8px 0 22px;border:0;background:transparent;color:var(--text-primary,#e6edf7);font:inherit;font-size:1.05rem;outline:none}
++.sc-room-box input::placeholder{color:var(--text-muted,#7386a3);opacity:1}
++.sc-room-box input::-webkit-search-cancel-button{display:none}
++.sc-room-box button{flex:none;display:grid;place-items:center;width:44px;height:44px;margin-right:6px;border:0;border-radius:50%;background:var(--room-fill,#3b82f6);color:var(--room-on,#fff);cursor:pointer;font-size:20px;transition:transform .18s}
++.sc-room-box button:hover{transform:translateX(2px)}
++.sc-room-box button:focus-visible{outline:2px solid var(--room-accent,#3b82f6);outline-offset:3px}
++.sc-room-start{background:var(--room-fill,#3b82f6)!important;color:var(--room-on,#fff)!important;border-color:transparent!important}
++.sc-room-chips{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}
++.sc-room-chips a{display:inline-flex;align-items:center;min-height:38px;padding:0 15px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:color-mix(in srgb,var(--room-accent,#3b82f6) 7%,var(--bg-card,#0f1729));color:var(--text-primary,#e6edf7);font-size:.9rem;font-weight:600;text-decoration:none;white-space:nowrap;transition:border-color .15s,color .15s}
++.sc-room-chips a:hover,.sc-room-chips a:focus-visible{border-color:var(--room-accent,#3b82f6);color:var(--room-strong,#60a5fa);outline:0}
++.sc-room-proof{list-style:none;display:flex;flex-wrap:wrap;gap:8px 22px;margin:22px 0 0;padding:0;font-size:.92rem;color:var(--text-secondary,#96a7c2)}
++.sc-room-proof li{display:inline-flex;align-items:baseline;gap:6px}
++.sc-room-proof b{color:var(--text-primary,#e6edf7);font-size:1.18rem;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
++.sc-room-proof em{font-style:normal;font-size:.7rem;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#7386a3)}
++.sc-room-proof .sc-room-live{display:inline-flex;align-items:center;gap:4px;color:var(--room-strong,#60a5fa)}
++.sc-room-live::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;animation:sc-breathe 2s ease-in-out infinite}
++.sc-room-pic{display:flex;justify-content:center;min-width:0}
++.sc-room-motif{position:relative;width:min(100%,340px);aspect-ratio:1;color:var(--room-accent,#3b82f6)}
++.sc-rm-glow{position:absolute;inset:20%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--room-accent,#3b82f6) 48%,transparent),transparent);filter:blur(10px);animation:sc-breathe 6s ease-in-out infinite}
++.sc-rm-orbit{position:absolute;border-radius:50%;border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 32%,transparent);animation:sc-spin 26s linear infinite}
++.sc-rm-orbit.o1{inset:15%}
++.sc-rm-orbit.o2{inset:1%;border-style:dashed;animation-duration:52s;animation-direction:reverse;opacity:.7}
++.sc-rm-orbit i{position:absolute;top:-6px;left:calc(50% - 6px);width:12px;height:12px;border-radius:50%;background:currentColor;box-shadow:0 0 18px currentColor}
++.sc-rm-orbit.o2 i:nth-child(2){top:auto;bottom:12%;left:-5px;width:9px;height:9px;opacity:.75}
++.sc-rm-core{position:absolute;inset:33%;display:grid;place-items:center;border-radius:30%;background:color-mix(in srgb,var(--room-accent,#3b82f6) 14%,var(--bg-card,#0f1729));border:1px solid color-mix(in srgb,var(--room-accent,#3b82f6) 42%,transparent);box-shadow:0 22px 52px -18px color-mix(in srgb,var(--room-accent,#3b82f6) 75%,transparent)}
++.sc-rm-core .ovk-i{width:48%;height:48%;stroke-width:1.6}
++.sc-room .ovk-rail{margin-top:clamp(26px,4vw,40px)}
++.sc-room-scenes{display:flex;flex-direction:column;gap:clamp(32px,6vw,76px);margin-top:clamp(40px,6vw,80px)}
++.sc-scene{display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);gap:clamp(18px,4vw,52px);align-items:center}
++.sc-scene.is-flip .sc-scene-text{order:2}
++.sc-scene-text h2{margin:0 0 8px;font-size:clamp(1.3rem,2.6vw,1.8rem);letter-spacing:-.02em;line-height:1.2;text-wrap:balance}
++.sc-scene-text p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;max-width:460px}
++.sc-scene-go{display:inline-flex;align-items:center;gap:4px;min-height:40px;margin-top:10px;color:var(--room-strong,#60a5fa);font-weight:700;text-decoration:none}
++.sc-scene-go:hover{text-decoration:underline}
++.sc-scene-stage{position:relative;min-width:0;border-radius:20px;padding:clamp(14px,2.4vw,24px);background:radial-gradient(120% 80% at 85% 0%,color-mix(in srgb,var(--room-accent,#3b82f6) 16%,transparent),transparent 62%),var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);box-shadow:0 30px 60px -32px rgba(0,0,0,.55);overflow:hidden}
++.sc-room-devlinks{margin:12px 0 0;font-size:.9rem;color:var(--text-muted,#7386a3)}
++.sc-room-devlinks a{color:var(--room-strong,#60a5fa);font-weight:700}
++html.ov-motion [data-ov-reveal]{opacity:0;transform:translateY(18px);transition:opacity .6s cubic-bezier(.2,.7,.2,1),transform .6s cubic-bezier(.2,.7,.2,1)}
++html.ov-motion [data-ov-reveal].is-in{opacity:1;transform:none}
++@media (max-width:759px){
++.sc-room-hero{grid-template-columns:minmax(0,1fr);margin-inline:-16px;padding-inline:16px;overflow-x:clip}
++.sc-room-pic{position:absolute;z-index:-1;top:-4px;right:-30px;width:168px;opacity:.28;pointer-events:none}
++.sc-scene,.sc-scene.is-flip{grid-template-columns:minmax(0,1fr)}.sc-scene.is-flip .sc-scene-text{order:0}
++}
++@media (max-width:599px){.sc-room-chips{flex-wrap:nowrap;overflow-x:auto;margin-inline:-16px;padding:2px 16px;scrollbar-width:none}.sc-room-chips::-webkit-scrollbar{display:none}.sc-room-box input{height:52px;padding-left:18px}}
++@media (prefers-reduced-motion:reduce){.sc-rm-glow,.sc-rm-orbit,.sc-room-live::before{animation:none}html.ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}}
++html[data-ov-motion=reduced] .sc-rm-glow,html[data-ov-motion=reduced] .sc-rm-orbit,html[data-ov-motion=reduced] .sc-room-live::before{animation:none}
++html[data-ov-motion=reduced].ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
ddiff --git a/showcase.js b/showcase.js
iindex f800bf4..41d1df7 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -333,7 +333,116 @@ function cta({ title, text, actions: acts } = {}) {
return `<section class="sc-cta"><h2>${esc(title)}</h2>${text ? `<p>${esc(text)}</p>` : ''}${actions(acts)}</section>`;
}
++// ── Rooms (plan 2026-10-28 §3.3): a room's front door, in the order every room shares ──
++let roomsLib = null, kitLib = null;
++const fmtNum = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v));
++/** The room's motif when it brings none: its icon in an aura of its accent, two slow orbits and their dots. */
++function roomMotif(r) {
++ kitLib = kitLib || require('./kit');
++ return `<div class="sc-room-motif" aria-hidden="true"><span class="sc-rm-glow"></span><span class="sc-rm-orbit o1"><i></i></span><span class="sc-rm-orbit o2"><i></i><i></i></span>`
++ + `<span class="sc-rm-core">${kitLib.icon(r ? r.icon : 'spark')}</span></div>`;
++}
++/**
++ * room({ room, hero, proof, rails, scenes, explore, dev, faq, network, html }) — a room's front door, in the order every
++ * room shares: hero → live proof → rails (Right now, For you…) → feature scenes → explore → for developers → questions →
++ * the rest of OpenVibe. Wrapped in .ov-room with the room's accent (rooms.js); what has nothing to show is left out.
++ * room: 'surf' (rooms.js id or host; an unknown one keeps the theme's accent)
++ * hero: { title (≤ 8 words), lede, search: { action, name, placeholder, hints: [typed in turn], label },
++ * start: { href, label } (when there is no box), chips: [{ label, href }] (≤ 5), motif (trusted HTML) }
++ * proof: [{ value, label, live: true } | { value, label, asOf: 'Oct 28' }] — real numbers only; 0 or none is left out
++ * rails: [{ id, title, more, items: [kit.card(...)], size }] — kit.rail
++ * scenes: [{ title, text, html (trusted: the room's own component in a demo state), action: { href, label } }]
++ * explore: { title, items: [{ href, title, text, icon }] } — sc.features · dev: sc.code's arguments, with links
++ * faq: { title, items: [[q, a]] } · network: sc.network's arguments · html: trusted markup after the scenes
++ * Needs kit.css (cards, rails, the room's accent) and showcase.css; motion.js makes the numbers tick and the box type.
++ */
++function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq: q, network: net, html } = {}) {
++ roomsLib = roomsLib || require('./rooms');
++ kitLib = kitLib || require('./kit');
++ const r = roomsLib.get(key);
++ const chips = (h.chips || []).filter((c) => c && c.label && safeHref(c.href)).slice(0, 5);
++ const box = h.search && h.search.action ? `<form class="sc-room-box" role="search" method="get" action="${esc(safeHref(h.search.action) || '#')}">`
++ + `<label class="sc-sr" for="sc-room-q">${esc(h.search.label || h.search.placeholder || 'Search')}</label>`
++ + `<input id="sc-room-q" type="search" name="${esc(h.search.name || 'q')}" placeholder="${esc(h.search.placeholder || '')}" autocomplete="off" enterkeyhint="search"${has(h.search.hints) ? ` data-ov-typed="${esc(h.search.hints.slice(0, 6).join('|'))}"` : ''}>`
++ + `<button type="submit" aria-label="${esc(h.search.button || 'Go')}">${kitLib.icon('forward')}</button></form>` : '';
++ const start = !box && h.start && safeHref(h.start.href) ? `<div class="sc-actions"><a class="sc-btn sc-primary sc-room-start" href="${esc(safeHref(h.start.href))}">${esc(h.start.label || 'Start')}</a></div>` : '';
++ const nums = (proof || []).filter((p) => p && p.label && p.value != null && p.value !== '' && p.value !== 0);
++ const proofHTML = nums.length ? `<ul class="sc-room-proof" aria-label="${esc(r ? `${r.name} right now` : 'Right now')}">${nums.map((p) => `<li>`
++ + `<b${typeof p.value === 'number' ? ` data-ov-ticker="${p.value}"` : ''}>${esc(fmtNum(p.value))}</b> <span>${esc(p.label)}</span>`
++ + `${p.live ? '<em class="sc-room-live">live</em>' : p.asOf ? `<em>${esc(p.asOf)}</em>` : ''}</li>`).join('')}</ul>` : '';
++ const heroHTML = h.title ? `<section class="sc-room-hero" aria-labelledby="sc-room-h">`
++ + `<div class="sc-room-main"><h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}${box}${start}`
++ + `${chips.length ? `<nav class="sc-room-chips" aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`
++ + `<div class="sc-room-pic">${h.motif != null ? h.motif : roomMotif(r)}</div></section>` : '';
++ const railsHTML = (rails || []).map((x) => kitLib.rail(x)).join('');
++ const sceneList = (scenes || []).filter((x) => x && x.title && x.html != null);
++ const scenesHTML = sceneList.length ? `<section class="sc-room-scenes" aria-label="What you can do">${sceneList.map((x, i) => `<article class="sc-scene${i % 2 ? ' is-flip' : ''}" data-ov-reveal>`
++ + `<div class="sc-scene-text"><h2>${esc(x.title)}</h2>${x.text ? `<p>${esc(x.text)}</p>` : ''}${x.action && safeHref(x.action.href) ? `<a class="sc-scene-go" href="${esc(safeHref(x.action.href))}">${esc(x.action.label || 'Try it')}${kitLib.icon('chevron')}</a>` : ''}</div>`
++ + `<div class="sc-scene-stage">${x.html}</div></article>`).join('')}</section>` : '';
++ const devLinks = dev && has(dev.links) ? `<p class="sc-room-devlinks">${dev.links.filter((l) => safeHref(l.href)).map((l) => `<a href="${esc(safeHref(l.href))}">${esc(l.label)}</a>`).join(' · ')}</p>` : '';
++ const devHTML = dev ? code(dev).replace(/<\/section>$/, `${devLinks}</section>`) : '';
++ return `<div class="ov-room sc-room"${r ? ` data-room="${esc(r.id)}" style="${roomsLib.style(r.id)}"` : ''}>${heroHTML}${railsHTML}${scenesHTML}${html != null ? html : ''}`
++ + `${explore ? features(explore) : ''}${devHTML}${q ? faq(q) : ''}${net ? network(net) : ''}</div>`;
++}
++
const CSS = `
++/* Rooms (4.28.0): sc.room() — a room's hero, live proof, motif and scenes, in the room's accent (.ov-room in kit.css:
++ --room-accent, --room-strong, --room-fill, --room-on). */
++.sc-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
++:where(.sc-room) .ovk-i{width:1.15em;height:1.15em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
++.sc-room-hero{position:relative;isolation:isolate;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,.8fr);gap:clamp(20px,4vw,48px);align-items:center;padding:clamp(20px,5vw,56px) 0 clamp(4px,2vw,16px)}
++.sc-room-hero h1{margin:0;font-size:clamp(2.05rem,5.4vw,3.5rem);font-weight:800;letter-spacing:-.035em;line-height:1.04;text-wrap:balance}
++.sc-room-lede{margin:14px 0 0;max-width:560px;font-size:clamp(1rem,1.6vw,1.13rem);line-height:1.55;color:var(--text-secondary,#96a7c2)}
++.sc-room-box{display:flex;align-items:center;margin-top:22px;max-width:560px;border-radius:999px;background:var(--bg-card,#0f1729);border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 45%,var(--border,#1f2d47));box-shadow:0 12px 32px -14px color-mix(in srgb,var(--room-accent,#3b82f6) 55%,transparent);transition:border-color .2s,box-shadow .2s}
++.sc-room-box:focus-within{border-color:var(--room-accent,#3b82f6);box-shadow:0 0 0 4px color-mix(in srgb,var(--room-accent,#3b82f6) 22%,transparent),0 12px 32px -14px color-mix(in srgb,var(--room-accent,#3b82f6) 60%,transparent)}
++.sc-room-box input{flex:1;min-width:0;height:56px;padding:0 8px 0 22px;border:0;background:transparent;color:var(--text-primary,#e6edf7);font:inherit;font-size:1.05rem;outline:none}
++.sc-room-box input::placeholder{color:var(--text-muted,#7386a3);opacity:1}
++.sc-room-box input::-webkit-search-cancel-button{display:none}
++.sc-room-box button{flex:none;display:grid;place-items:center;width:44px;height:44px;margin-right:6px;border:0;border-radius:50%;background:var(--room-fill,#3b82f6);color:var(--room-on,#fff);cursor:pointer;font-size:20px;transition:transform .18s}
++.sc-room-box button:hover{transform:translateX(2px)}
++.sc-room-box button:focus-visible{outline:2px solid var(--room-accent,#3b82f6);outline-offset:3px}
++.sc-room-start{background:var(--room-fill,#3b82f6)!important;color:var(--room-on,#fff)!important;border-color:transparent!important}
++.sc-room-chips{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}
++.sc-room-chips a{display:inline-flex;align-items:center;min-height:38px;padding:0 15px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:color-mix(in srgb,var(--room-accent,#3b82f6) 7%,var(--bg-card,#0f1729));color:var(--text-primary,#e6edf7);font-size:.9rem;font-weight:600;text-decoration:none;white-space:nowrap;transition:border-color .15s,color .15s}
++.sc-room-chips a:hover,.sc-room-chips a:focus-visible{border-color:var(--room-accent,#3b82f6);color:var(--room-strong,#60a5fa);outline:0}
++.sc-room-proof{list-style:none;display:flex;flex-wrap:wrap;gap:8px 22px;margin:22px 0 0;padding:0;font-size:.92rem;color:var(--text-secondary,#96a7c2)}
++.sc-room-proof li{display:inline-flex;align-items:baseline;gap:6px}
++.sc-room-proof b{color:var(--text-primary,#e6edf7);font-size:1.18rem;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
++.sc-room-proof em{font-style:normal;font-size:.7rem;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#7386a3)}
++.sc-room-proof .sc-room-live{display:inline-flex;align-items:center;gap:4px;color:var(--room-strong,#60a5fa)}
++.sc-room-live::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;animation:sc-breathe 2s ease-in-out infinite}
++.sc-room-pic{display:flex;justify-content:center;min-width:0}
++.sc-room-motif{position:relative;width:min(100%,340px);aspect-ratio:1;color:var(--room-accent,#3b82f6)}
++.sc-rm-glow{position:absolute;inset:20%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--room-accent,#3b82f6) 48%,transparent),transparent);filter:blur(10px);animation:sc-breathe 6s ease-in-out infinite}
++.sc-rm-orbit{position:absolute;border-radius:50%;border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 32%,transparent);animation:sc-spin 26s linear infinite}
++.sc-rm-orbit.o1{inset:15%}
++.sc-rm-orbit.o2{inset:1%;border-style:dashed;animation-duration:52s;animation-direction:reverse;opacity:.7}
++.sc-rm-orbit i{position:absolute;top:-6px;left:calc(50% - 6px);width:12px;height:12px;border-radius:50%;background:currentColor;box-shadow:0 0 18px currentColor}
++.sc-rm-orbit.o2 i:nth-child(2){top:auto;bottom:12%;left:-5px;width:9px;height:9px;opacity:.75}
++.sc-rm-core{position:absolute;inset:33%;display:grid;place-items:center;border-radius:30%;background:color-mix(in srgb,var(--room-accent,#3b82f6) 14%,var(--bg-card,#0f1729));border:1px solid color-mix(in srgb,var(--room-accent,#3b82f6) 42%,transparent);box-shadow:0 22px 52px -18px color-mix(in srgb,var(--room-accent,#3b82f6) 75%,transparent)}
++.sc-rm-core .ovk-i{width:48%;height:48%;stroke-width:1.6}
++.sc-room .ovk-rail{margin-top:clamp(26px,4vw,40px)}
++.sc-room-scenes{display:flex;flex-direction:column;gap:clamp(32px,6vw,76px);margin-top:clamp(40px,6vw,80px)}
++.sc-scene{display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);gap:clamp(18px,4vw,52px);align-items:center}
++.sc-scene.is-flip .sc-scene-text{order:2}
++.sc-scene-text h2{margin:0 0 8px;font-size:clamp(1.3rem,2.6vw,1.8rem);letter-spacing:-.02em;line-height:1.2;text-wrap:balance}
++.sc-scene-text p{margin:0;color:var(--text-secondary,#96a7c2);line-height:1.6;max-width:460px}
++.sc-scene-go{display:inline-flex;align-items:center;gap:4px;min-height:40px;margin-top:10px;color:var(--room-strong,#60a5fa);font-weight:700;text-decoration:none}
++.sc-scene-go:hover{text-decoration:underline}
++.sc-scene-stage{position:relative;min-width:0;border-radius:20px;padding:clamp(14px,2.4vw,24px);background:radial-gradient(120% 80% at 85% 0%,color-mix(in srgb,var(--room-accent,#3b82f6) 16%,transparent),transparent 62%),var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);box-shadow:0 30px 60px -32px rgba(0,0,0,.55);overflow:hidden}
++.sc-room-devlinks{margin:12px 0 0;font-size:.9rem;color:var(--text-muted,#7386a3)}
++.sc-room-devlinks a{color:var(--room-strong,#60a5fa);font-weight:700}
++html.ov-motion [data-ov-reveal]{opacity:0;transform:translateY(18px);transition:opacity .6s cubic-bezier(.2,.7,.2,1),transform .6s cubic-bezier(.2,.7,.2,1)}
++html.ov-motion [data-ov-reveal].is-in{opacity:1;transform:none}
++@media (max-width:759px){
++.sc-room-hero{grid-template-columns:minmax(0,1fr);margin-inline:-16px;padding-inline:16px;overflow-x:clip}
++.sc-room-pic{position:absolute;z-index:-1;top:-4px;right:-30px;width:168px;opacity:.28;pointer-events:none}
++.sc-scene,.sc-scene.is-flip{grid-template-columns:minmax(0,1fr)}.sc-scene.is-flip .sc-scene-text{order:0}
++}
++@media (max-width:599px){.sc-room-chips{flex-wrap:nowrap;overflow-x:auto;margin-inline:-16px;padding:2px 16px;scrollbar-width:none}.sc-room-chips::-webkit-scrollbar{display:none}.sc-room-box input{height:52px;padding-left:18px}}
++@media (prefers-reduced-motion:reduce){.sc-rm-glow,.sc-rm-orbit,.sc-room-live::before{animation:none}html.ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}}
++html[data-ov-motion=reduced] .sc-rm-glow,html[data-ov-motion=reduced] .sc-rm-orbit,html[data-ov-motion=reduced] .sc-room-live::before{animation:none}
++html[data-ov-motion=reduced].ov-motion [data-ov-reveal]{opacity:1;transform:none;transition:none}
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
@@@ -501,4 +610,4 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
`;
--module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
++module.exports = { room, hero, features, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
ddiff --git a/test/room-kit.test.js b/test/room-kit.test.js
nnew file mode 100644
iindex 0000000..a8c2b4f
---- /dev/null
++++ b/test/room-kit.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++// The room kit (4.28.0, plan 2026-10-28 §3.3-§3.5): rooms.js (accents that pass AA in both modes), kit.card, kit.rail,
++// sc.room and motion.js's markup contract.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const rooms = require('../rooms');
++const kit = require('../kit');
++const sc = require('../showcase');
++
++// ── rooms.js: fourteen rooms, accents 25° of hue apart, each AA in its mode ──
++{
++ assert.strictEqual(rooms.IDS.length, 14);
++ const lum = (hex) => {
++ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
++ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
++ };
++ const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
++ const hues = new Set();
++ for (const id of rooms.IDS) {
++ const r = rooms.get(id);
++ assert.ok(ratio(r.dark, '#0B0D12') >= 4.5, `${id}: dark accent ${r.dark} on #0B0D12 is ${ratio(r.dark, '#0B0D12').toFixed(2)}`);
++ assert.ok(ratio(r.light, '#FFFFFF') >= 4.5, `${id}: light accent ${r.light} on white is ${ratio(r.light, '#FFFFFF').toFixed(2)}`);
++ assert.ok(r.line.split(/\s+/).length <= 8, `${id}: the hero line is eight words at most`);
++ assert.ok(kit.ICONS.includes(r.icon), `${id}: its icon ${r.icon} is in the kit`);
++ assert.ok(rooms.HOODS[r.hood], `${id}: ${r.hood} is a neighbourhood`);
++ hues.add(r.hue);
++ }
++ assert.strictEqual(hues.size, 14, 'every room has its own hue');
++ assert.strictEqual(rooms.get('www.open.shoes').id, 'shoes', 'a room by its host');
++ assert.strictEqual(rooms.get('openvibe.live'), null, 'a site that is not a room');
++ assert.strictEqual(rooms.style('surf'), '--room-dark:#00BEF6;--room-light:#0072AB');
++ assert.strictEqual(rooms.style('nope'), '');
++}
++
++// ── kit.card: one anatomy, escaped, honest counts ──
++{
++ const c = kit.card({ href: '/runs/1', title: '<b>Run</b>', type: 'run', who: 'goose"ly', count: [1832, 'views'], corner: '1:02.4', badge: 'WR', save: { href: '/save/1' }, when: '2026-10-28T09:00:00Z' });
++ assert.ok(c.includes('&lt;b&gt;Run&lt;/b&gt;') && !c.includes('<b>Run'), 'the title is escaped');
++ assert.ok(c.includes('style="--ovk-ar:16/9"'), 'a run is 16:9');
++ assert.ok(c.includes('<span>1.8k views</span>'), 'counts are short');
++ assert.ok(c.includes('class="ovk-card-badge">WR<') && c.includes('class="ovk-card-corner">1:02.4<'));
++ assert.ok(c.includes('class="ovk-card-link" href="/runs/1"'), 'the title is the link');
++ assert.ok(c.includes('data-ovk-share="&lt;b&gt;Run&lt;/b&gt;"'), 'Share is always offered');
++ assert.ok(c.includes('href="/save/1"') && c.includes('Save'), 'Save when given');
++ assert.ok(!c.includes('Remind me'), 'no Remind unless given');
++ assert.ok(c.includes('class="ovk-card-noimg"'), 'no picture: the type\'s icon');
++ assert.ok(!kit.card({ title: 'x', count: [0, 'views'] }).includes('0 views'), 'a count of 0 is left out');
++ assert.ok(!kit.card({ title: 'x', count: null }).includes('ovk-card-meta'), 'nothing to say: no meta line');
++ assert.ok(kit.card({ title: 'x', type: 'shoe' }).includes('--ovk-ar:1/1'), 'a shoe is square');
++ assert.ok(kit.card({ title: 'x', type: 'domain' }).includes('--ovk-ar:2/1'), 'a domain is 2:1');
++ assert.ok(kit.card({ title: 'x', ratio: '9/16' }).includes('--ovk-ar:9/16'), 'ratio overrides');
++ assert.ok(kit.card({ title: 'x', ratio: '1;color:red' }).includes('--ovk-ar:16/9'), 'a bad ratio is ignored');
++ assert.ok(kit.card({ title: 'x', href: 'javascript:alert(1)' }).includes('href="#"'), 'a script URL never lands');
++ assert.ok(kit.card({ title: 'x', image: 'javascript:x' }).includes('ovk-card-noimg'), 'a bad picture URL is not used');
++ assert.ok(kit.card({ title: 'x', live: true }).includes('ovk-card-badge is-live">LIVE'), 'live says LIVE');
++ assert.ok(kit.card({ title: 'x', video: '/v.mp4' }).includes('data-src="/v.mp4"') && !kit.card({ title: 'x', video: '/v.mp4' }).includes(' src="/v.mp4"'), 'the preview loads only when hovered');
++ assert.ok(kit.card({ title: 'x', level: 2 }).includes('<h2 class="ovk-card-title">'));
++}
++
++// ── kit.rail ──
++{
++ assert.strictEqual(kit.rail({ title: 'Empty', items: [] }), '', 'an empty rail is left out');
++ const r = kit.rail({ id: 'now', title: 'Right now', more: { href: '/live', label: 'All' }, items: ['<i>a</i>', '<i>b</i>'], size: 's' });
++ assert.ok(r.includes('class="ovk-rail ovk-rail--s" id="now" aria-labelledby="now-h"') && r.includes('<h2 id="now-h">Right now</h2>'));
++ assert.strictEqual((r.match(/<li>/g) || []).length, 2);
++ assert.ok(r.includes('ovk-rail-prev') && r.includes('ovk-rail-next') && r.includes(' hidden>'), 'arrows start hidden (kit-web shows them)');
++ assert.ok(kit.rail({ items: ['x'], size: 'huge' }).includes('ovk-rail--m'), 'an unknown size is medium');
++}
++
++// ── sc.room: the front door's order, the accent, nothing invented ──
++{
++ const html = sc.room({
++ room: 'surf',
++ hero: { title: 'Ride maps', lede: 'L', search: { action: '/search', placeholder: 'Find', hints: ['a', 'b'] }, chips: [1, 2, 3, 4, 5, 6].map((n) => ({ label: `c${n}`, href: `/c${n}` })) },
++ proof: [{ value: 1204, label: 'runs', live: true }, { value: 0, label: 'zero' }, { value: 12, label: 'maps', asOf: 'Oct 28' }],
++ rails: [{ id: 'now', title: 'Right now', items: [kit.card({ title: 'x' })] }],
++ scenes: [{ title: 'Race', text: 't', html: '<div id="stage"></div>' }],
++ explore: { title: 'Explore', items: [{ title: 'Ride', href: '/ride' }] },
++ dev: { title: 'For developers', samples: [{ label: 'SDK', code: 'x()' }], links: [{ label: 'OpenAPI', href: '/openapi.json' }] },
++ faq: { items: [['Q?', 'A.']] },
++ });
++ assert.ok(html.startsWith('<div class="ov-room sc-room" data-room="surf" style="--room-dark:#00BEF6;--room-light:#0072AB">'), 'the room wears its accent');
++ const order = ['sc-room-hero', 'ovk-rail', 'sc-room-scenes', 'Explore', 'For developers', 'Q?'].map((k) => html.indexOf(k));
++ assert.ok(order.every((v, i) => v > 0 && (i === 0 || v > order[i - 1])), `the parts come in order: ${order}`);
++ assert.strictEqual((html.match(/<nav class="sc-room-chips"[\s\S]*?<\/nav>/)[0].match(/<a /g) || []).length, 5, 'five chips at most');
++ assert.ok(html.includes('data-ov-ticker="1204">1,204</b>'), 'the real number is written; motion.js only counts up to it');
++ assert.ok(!html.includes('zero'), 'a 0 is left out');
++ assert.ok(html.includes('<em class="sc-room-live">live</em>') && html.includes('<em>Oct 28</em>'), 'every number is live or dated');
++ assert.ok(html.includes('data-ov-typed="a|b"'), 'the box types its hints');
++ assert.ok(html.includes('data-ov-reveal'), 'scenes reveal');
++ assert.ok(/<p class="sc-room-devlinks">.*OpenAPI.*<\/p><\/section>/.test(html), 'the developer links sit in their section');
++ assert.ok(!sc.room({ room: 'nope', hero: { title: 'x' } }).includes('style='), 'an unknown room keeps the theme\'s accent');
++ assert.ok(sc.room({ room: 'surf', hero: { title: 'x', start: { href: '/go', label: 'Start surfing' } } }).includes('sc-room-start'), 'one Start when there is no box');
++ assert.ok(sc.room({ room: 'surf', hero: { title: 'x' } }).includes('sc-room-motif'), 'the default motif');
++ assert.ok(sc.room({ room: 'surf', hero: { title: 'x', motif: '<canvas id="m"></canvas>' } }).includes('<canvas id="m">'), 'a room brings its own motif');
++}
++
++// ── The CSS is served and carries the room accent rules; motion.js keeps the server's end state ──
++{
++ const kcss = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
++ assert.ok(kcss.includes(':root[data-theme-mode="light"] .ov-room[style*="--room-light"]'), 'light themes take the light accent');
++ assert.ok(kcss.includes('.ovk-rail-btn { display: none; }'), 'phones swipe rails');
++ const scss = fs.readFileSync(path.join(__dirname, '..', 'showcase.css'), 'utf8');
++ assert.ok(scss.includes('.sc-room-hero{') && scss.includes('html.ov-motion [data-ov-reveal]'), 'showcase.css is rebuilt (npm run build:styles)');
++ const motion = fs.readFileSync(path.join(__dirname, '..', 'motion.js'), 'utf8');
++ assert.ok(/el\.textContent = k < 1 \? .* : text;/.test(motion), 'the ticker ends on the server\'s text');
++ assert.ok(motion.includes("html.getAttribute('data-ov-motion') === 'reduced'"), 'the account\'s reduced motion is honoured');
++}
++
++console.log('room kit: rooms, card, rail, sc.room and motion ok');
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex f8fd7fe..59b245e 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -69,7 +69,9 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('!!document.getElementById("openvibe-search-btn")'), true, 'the magnifier is in the bar');
assert.strictEqual(await p.evaluate('typeof window.OpenVibeSearch'), 'undefined', 'search.js is not loaded until asked');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
-- await wait(400);
++ // search.js loads on first use: wait for its box, not a fixed time (macOS runners took longer than 400 ms, 4.28.0's CI).
++ for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
++ await wait(150);
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
tabs: [...document.querySelectorAll('.ovs-tab')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),