openvibers/openvibe.shared Commits

4.29.1: room.css (cards, rails and room accents out of kit.css: 18 sites' CSS budgets), motion.js/rooms.js/room.css served at /shared/, ring icons for the new rooms (#161)

OP OpenVibers committed 16 days ago · 036a9a8ff4 · 12 files, +120 −76

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 50a1e3c..e523d90 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,29 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.29.1 — 2026-10-28
++
++- **`room.css`**: the room kit's cards, rails and room accents leave `kit.css` for a stylesheet of their own.
++ - `kit.css` stays the controls only, so sites that use the kit's controls are not charged for components they
++ never draw. 4.28.0 had made 18 sites' CSS budgets fail on the pin round.
++ - Pages with `kit.card`, `kit.rail` or `sc.room()` load `room.css` after `kit.css`.
++- **`motion.js`, `rooms.js` and `room.css` are served at `/shared/`** (they were missing from `files.js`'s lists in
++ 4.28.0, so `/shared/motion.js` answered 404).
++- **The new rooms have ring icons:** each room id resolves to the nearest existing glyph until it gets its own, so the
++ menu never draws the fallback mark. The mapping:
++ - domains: globe;
++ - design: palette;
++ - today: clock;
++ - report: gauge;
++ - surf and casino: games;
++ - fun: quest;
++ - meme: image;
++ - exchange: trade;
++ - cam: video;
++ - tours: map;
++ - vision: AI;
++ - shoes: deals.
++
## 4.29.0 — 2026-10-28
- **Every result, in the OpenVibe menu** (the Search Fabric, plan 2026-10-28 §4.5; Contracts 0.225.0
ddiff --git a/README.md b/README.md
iindex 2822faf..b36744b 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.29.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.29.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 2d6a64b..1a0d9e6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.29.0",
++ "version": "4.29.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/home-pages.md b/docs/home-pages.md
iindex edb3493..f012983 100644
---- a/docs/home-pages.md
++++ b/docs/home-pages.md
@@@ -71,7 +71,7 @@ It renders the parts in the order every room shares (plan 2026-10-28 §3.3), wra
| 7 | Questions, the rest of OpenVibe | |
The page loads:
--- `kit.css` and `showcase.css`;
++- `kit.css`, `room.css` (cards, rails, the room's accent) and `showcase.css`;
- `kit-web.js` for rails, menus and share;
- `motion.js` for the ticker, the typed hints and the reveal.
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 735388b..8036db1 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -227,6 +227,8 @@ A rail scrolls sideways and snaps:
A card's menu inside a rail is placed on the screen, so the scroller never clips it. An empty rail is `''`.
++Cards, rails and a room's accent are drawn by `room.css` (4.29.1), loaded after `kit.css` on the pages that use them, so a page with only the kit's controls does not pay for them.
++
### Rooms (4.28.0)
`require('openvibe-shared/rooms')` (`window.OpenVibeRooms`) holds the fourteen rooms. Each has a neighbourhood, a host,
ddiff --git a/files.js b/files.js
iindex e395e0a..cb5fcee 100644
---- a/files.js
++++ b/files.js
@@@ -44,6 +44,8 @@ const BROWSER = Object.freeze([
'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use
'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows
'agent-session.js', // a coding agent's session as people read it: steps grouped, live output, diffs, helpers, hand-offs
++ 'motion.js', // reveal on scroll, number ticker, typed hints (4.28.0; the server writes the end state)
++ 'rooms.js', // the rooms' accents, lines and motifs (OpenVibeRooms; 4.28.0)
'openvibe-sw.js', // Web Push service worker
]);
@@@ -54,6 +56,7 @@ const STYLES = Object.freeze([
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
++ 'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
ddiff --git a/kit.css b/kit.css
iindex a78e02b..398c63e 100644
---- a/kit.css
++++ b/kit.css
@@@ -233,80 +233,14 @@ 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; }
@@@ -317,6 +251,5 @@ 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, .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 (prefers-reduced-motion: reduce) { .ovk-switch, .ovk-switch::after, .ovk-fold > summary > .ovk-i, .ovk-head, .ovk-btn { transition: none; } }
@media print { .ovk-head, .ovk-dock, .ovk-save { position: static; background: none; -webkit-backdrop-filter: none; backdrop-filter: none; } }
ddiff --git a/ov-icons.js b/ov-icons.js
iindex b42a146..c28c06f 100644
---- a/ov-icons.js
++++ b/ov-icons.js
@@@ -107,7 +107,9 @@
};
// [dx, dy] in glyph units that move each glyph's drawn bounds onto the centre of the ring. Generated.
const OFFSETS = {"ov":[0,-0.5],"network":[0.56,-0.39],"live":[-0.01,-0.38],"tools":[0,-0.39],"media":[0,-0.39],"games":[0,-0.39],"community":[0,-0.39],"chat":[0,-0.39],"codes":[0,-0.39],"bot":[0,-0.39],"code":[0,-0.39],"blog":[0,-0.39],"wiki":[-0.01,-0.39],"news":[0,-0.39],"reviews":[-0.02,-0.39],"tips":[0,-0.39],"vip":[-0.01,-0.39],"trade":[0,-0.39],"host":[0,-0.39],"deals":[0.19,-0.21],"coupons":[-0.01,-0.39],"stream":[0,-0.39],"video":[-0.01,-0.39],"download":[0,-0.39],"upload":[0,-0.39],"image":[0,-0.39],"audio":[0,-0.38],"pdf":[0.28,-0.39],"docs":[0,-0.39],"text":[-0.01,-0.39],"logo":[-1.25,-0.5],"dns":[0,-0.39],"ip":[0,-0.37],"ssl":[-0.01,-0.39],"ping":[0,-0.39],"whois":[0,-0.39],"search":[0,-0.39],"map":[-0.01,-0.39],"food":[-0.01,-0.39],"gauge":[0,-0.39],"link":[0,-0.39],"shield":[0,-0.33],"plug":[0,-0.39],"work":[0,0.17],"rent":[0,-0.39],"help":[0,-0.39],"quest":[-0.01,-0.39],"inventory":[0,-0.37],"play":[0,-0.39],"drive":[0,-0.39],"services":[-0.01,-0.39],"space":[0,-0.39],"watch":[-0.02,-0.39],"actor":[0,-0.39],"events":[-0.01,-0.39],"ai":[0,-0.39],"run":[-0.01,-0.39],"billing":[0,-0.39],"feeds":[-0.01,-0.39],"pics":[-0.01,-0.39],"homes":[-0.01,-0.39],"website":[0,-0.39],"zone":[-0.01,-0.39],"fund":[-0.01,-0.39],"paste":[0,-0.39],"account":[-0.01,-0.39],"bell":[-0.01,-0.39],"history":[0,-0.39],"theme":[0,-0.39],"check":[-0.01,-0.39],"error":[0,-0.39],"clip":[0.13,-0.39]};
-- const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell', 'media-hub': 'drive', mediahub: 'drive', sources: 'feeds', agent: 'actor', jobs: 'work', homes: 'homes', page: 'docs', db: 'zone', database: 'zone', location: 'ip', gamepad: 'games', robot: 'bot', deploy: 'upload', copy: 'paste', activity: 'ping' };
++ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell', 'media-hub': 'drive', mediahub: 'drive', sources: 'feeds', agent: 'actor', jobs: 'work', homes: 'homes', page: 'docs', db: 'zone', database: 'zone', location: 'ip', gamepad: 'games', robot: 'bot', deploy: 'upload', copy: 'paste', activity: 'ping',
++ // The 2026-10-28 rooms, on the nearest glyph until each has its own (rooms.js): never the fallback mark.
++ domains: 'dns', design: 'theme', today: 'history', report: 'gauge', surf: 'games', casino: 'games', fun: 'quest', meme: 'image', exchange: 'trade', cam: 'video', tours: 'map', vision: 'ai', shoes: 'deals' };
const registry = Object.create(null);
for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
ddiff --git a/package.json b/package.json
iindex 5b5017c..9cd353a 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.29.0",
++ "version": "4.29.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/room.css b/room.css
nnew file mode 100644
iindex 0000000..a7fbd08
---- /dev/null
++++ b/room.css
@@@ -0,0 +1,77 @@
++/* openvibe-shared/room.css — the room kit's look (4.29.1; before, in kit.css): a room's accent (rooms.js), kit.card and
++ * kit.rail. A page that shows cards or rails, or wears a room's accent (sc.room() does all three), loads it after
++ * kit.css; pages that use only the kit's controls do not pay for it. Uses kit.css's tokens (--ovk-*). */
++
++/* ── 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; }
++}
++@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; }
++}
++@media (prefers-reduced-motion: reduce) { .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; }
ddiff --git a/showcase.js b/showcase.js
iindex 41d1df7..88b5e58 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -354,7 +354,7 @@ function roomMotif(r) {
* 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.
++ * Needs kit.css, room.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');
ddiff --git a/test/room-kit.test.js b/test/room-kit.test.js
iindex a8c2b4f..1232427 100644
---- a/test/room-kit.test.js
++++ b/test/room-kit.test.js
@@@ -98,9 +98,13 @@ const sc = require('../showcase');
// ── The CSS is served and carries the room accent rules; motion.js keeps the server's end state ──
{
++ const rcss = fs.readFileSync(path.join(__dirname, '..', 'room.css'), 'utf8');
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');
++ assert.ok(rcss.includes(':root[data-theme-mode="light"] .ov-room[style*="--room-light"]'), 'light themes take the light accent');
++ assert.ok(rcss.includes('.ovk-rail-btn { display: none; }'), 'phones swipe rails');
++ assert.ok(!/ovk-card|ovk-rail|ov-room/.test(kcss), 'kit.css stays the controls only: cards, rails and accents are room.css (4.29.1)');
++ const files = require('../files');
++ for (const f of ['motion.js', 'rooms.js', 'room.css']) assert.ok(files.isBrowserFile(f), `${f} is served at /shared/`);
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');