openvibers/openvibe.shared Commits
4.28.0: the room kit (rooms, kit.card, kit.rail, sc.room, motion) (#159)
Changes
@@@ -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:@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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.@@@ -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 linksThe 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:@@@ -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);++ });}@@@ -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; } }@@@ -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,nnew file mode 100644@@@ -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);@@@ -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",nnew file mode 100644@@@ -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 };++});@@@ -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}@@@ -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 };nnew file mode 100644@@@ -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('<b>Run</b>') && !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="<b>Run</b>"'), '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');@@@ -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),
@@ -6,6 +6,44 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,44 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.27.0 — 2026-10-28
- **Notifications 2.0** (`notification-ui.js`). The owner's screenshot on 10-28: three red "Alert:
- **Notifications 2.0** (`notification-ui.js`). The owner's screenshot on 10-28: three red "Alert:
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.27.0",
+ "version": "4.28.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -47,6 +47,36 @@ ${sc.cta({ title: 'Ready?', actions: [{ label: 'Start', href: '/start', primary:
@@ -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 | |
| `nextSteps` | What to do next on other products after finishing something | |
| `cta` | The closing call to action | |
| `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
## 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.
- **Escaping:** every string is escaped. Only a field named `html` passes markup through, and it must be the caller's own.
@@ -196,6 +196,53 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.
@@ -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`,
`search`, `gear`, `members`, `invite`, `chat`, `bell`, `home`, `spark`, `live`, `video`, `image`, `tag`, `coin`,
`trophy`, `map`, `code`, `robot` and `game`.
`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 navbar's links
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
@@ -11,6 +11,10 @@
@@ -11,6 +11,10 @@
* sliders input[type=range][data-ovk-unit] say their value
* 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
* 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
* 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.
* Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces.
*/
*/
@@ -39,7 +43,22 @@
@@ -39,7 +43,22 @@
// Menus: one open at a time; a click elsewhere or Escape closes them.
// Menus: one open at a time; a click elsewhere or Escape closes them.
all('details.ovk-more').forEach((d) => {
all('details.ovk-more').forEach((d) => {
if (!once(d, 'ovkMore')) return;
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) {
if (!document.documentElement.dataset.ovkDoc) {
document.documentElement.dataset.ovkDoc = '1';
document.documentElement.dataset.ovkDoc = '1';
@@ -99,6 +118,54 @@
@@ -99,6 +118,54 @@
});
});
// Tabs that scroll sideways keep the open one in view.
// 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' }); });
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);
+ });
}
}
@@ -233,13 +233,80 @@ div.ovk-set-row { cursor: default; }
@@ -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; }
.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 ── */
/* ── 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) {
@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-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-row > a, .ovk-row--static { padding-left: 12px; }
.ovk-head-tools { gap: 0; }
.ovk-head-tools { gap: 0; }
}
}
/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */
/* ── Tablets and up (≥ 600 px): numbers in a row, roomier lists ── */
@media (min-width: 600px) {
@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-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-save .ovk-btn { width: auto; min-width: 200px; }
.ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
.ovk-dock { margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; }
@@ -250,5 +317,6 @@ div.ovk-set-row { cursor: default; }
@@ -250,5 +317,6 @@ div.ovk-set-row { cursor: default; }
.ovk-split { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
.ovk-split { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
.ovk-split > .ovk-side { position: sticky; top: calc(var(--ovk-top) + 12px); }
.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; } }
@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
@@ -94,7 +94,11 @@
@@ -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"/>',
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"/>',
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"/>',
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));
const ICONS = Object.freeze(Object.keys(P));
/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
@@ -227,6 +231,7 @@
@@ -227,6 +231,7 @@
const inner = `${it.icon ? icon(it.icon) : ''}<span>${esc(it.label)}</span>`;
const inner = `${it.icon ? icon(it.icon) : ''}<span>${esc(it.label)}</span>`;
const cls = `ovk-pop-item${it.danger ? ' ovk-pop-item--danger' : ''}`;
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.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 `<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>`;
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 @@
@@ -306,8 +311,75 @@
// markup with these builders loads both and calls enhance() on what it added. A no-op on the server.
// 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);
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 {
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,
head, tabs, tabOf, list, row, section, fold, more,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 1,
enhance, VERSION: 1,
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -79,7 +79,9 @@
@@ -79,7 +79,9 @@
"./user-card": "./user-card.js",
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
"./web-runtime": "./web-runtime.js",
"./route-transition": "./route-transition.js",
"./route-transition": "./route-transition.js",
- "./boost": "./boost.js"
+ "./boost": "./boost.js",
+ "./rooms": "./rooms.js",
+ "./motion": "./motion.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
+});
@@ -1,4 +1,61 @@
@@ -1,4 +1,61 @@
/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */
/* 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. */
/* 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-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}
.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}
@@ -333,7 +333,116 @@ function cta({ title, text, actions: acts } = {}) {
@@ -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>`;
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 = `
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. */
/* 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-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}
.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)}
@@ -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}}
@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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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('<b>Run</b>') && !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="<b>Run</b>"'), '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');
@@ -69,7 +69,9 @@ const server = http.createServer((req, res) => {
@@ -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('!!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');
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 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,
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' ? '*' : '')),
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),
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),