openvibers/openvibe.design Commits

openvibe.design/kit: the OpenVibe Kit, part by part (plan L54 K3) (#88)

OP OpenVibers committed 12 days ago · 2dd44331a6 · 8 files, +608 −1

- /kit: all 45 parts of openvibe-shared/kit by group (actions, content, layout, forms, data, tools,
  feedback), each drawn small with its one line and what its CSS weighs; the helpers; three ways
  in (server, a page that draws itself from the CDN, copy the HTML) with the files' real sizes.
  On a phone each group is a sideways rail.
- /kit/:part: the part drawn for real on a stage, the server line, the same in a page from the
  CDN, the HTML it returns (a counted fold), its weight against the kit's CSS, Compose and SwiftUI
  where they exist, and the parts either side.
- Each sample is a line of code that is both shown and run, so the snippet never drifts from the
  picture; a test runs every shown snippet and compares. Weights are measured from the installed
  kit.css and kit-data.css: a rule is a part's when a selector names only that part's classes.
- Kit joins the More menu and the sitemap; layout() can add Shared stylesheets.

Changes

ddiff --git a/public/css/kit-pages.css b/public/css/kit-pages.css
nnew file mode 100644
iindex 0000000..4669001
---- /dev/null
++++ b/public/css/kit-pages.css
@@@ -0,0 +1,106 @@
++/* openvibe.design/kit (plan L54 K3): the OpenVibe Kit, part by part.
++ The index draws every part small, by group (a sideways rail per group on a phone, so 45 parts never make a long
++ page); a part's page draws it for real on a stage, then the code, the weight and the parts either side. */
++.kt { padding-top: clamp(18px, 4vw, 40px); padding-bottom: 48px; }
++.kt-kicker { margin: 0 0 6px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
++.kt-hero h1, .kt-part-h h1 { margin: 0; font-size: clamp(1.8rem, 4.6vw, 3rem); line-height: 1.05; letter-spacing: -.035em; text-wrap: balance; }
++.kt-hero h1 { max-width: 20ch; }
++.kt-lede { margin: 12px 0 0; max-width: 68ch; color: var(--text-secondary, #96a7c2); line-height: 1.6; font-size: 1.02rem; }
++.kt code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .86em; }
++
++/* Groups, as a rail of links */
++.kt-gnav { display: flex; gap: 6px; margin: 22px 0 0; padding: 2px 0 8px; overflow-x: auto; scrollbar-width: thin; }
++.kt-gnav a { flex: none; display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 13px; border: 1px solid var(--border, #26344d); border-radius: 999px;
++ color: var(--text-primary, #e6edf7); font-weight: 750; font-size: .86rem; text-decoration: none; background: var(--bg-card, #0f1729); }
++.kt-gnav a span { color: var(--text-muted, #7386a3); font-weight: 700; font-variant-numeric: tabular-nums; }
++.kt-gnav a:hover { border-color: color-mix(in srgb, var(--room-strong) 50%, var(--border, #26344d)); }
++.kt-gnav a:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 2px; }
++
++/* Use it: three ways in */
++.kt-use { margin-top: 26px; }
++.kt-use > h2, .kt-group-h h2, .kt-code > h2, .kt-weight > h2 { margin: 0; font-size: clamp(1.15rem, 2.4vw, 1.45rem); letter-spacing: -.015em; }
++.kt-use-grid { display: grid; gap: 12px; margin-top: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); align-items: start; }
++.kt-use-card { min-width: 0; padding: 16px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); }
++.kt .kt-use-card h3, .kt .kt-code h3 { margin: 0; font-size: .95rem; font-weight: 800; letter-spacing: 0; text-transform: none; color: var(--text-primary, #e6edf7); }
++.kt-use-card p { margin: 6px 0 10px; color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.55; }
++.kt-files { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 4px; }
++.kt-file { display: flex; justify-content: space-between; gap: 10px; padding: 7px 10px; border-radius: 9px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); font-size: .86rem; }
++.kt-file span { color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++
++/* A group and its tiles */
++.kt-group { margin-top: 34px; scroll-margin-top: 72px; }
++.kt-group-h { display: flex; align-items: baseline; gap: 10px; }
++.kt-group-h span { padding: 1px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-strong) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .78rem; }
++.kt-group-l { margin: 4px 0 0; color: var(--text-muted, #7386a3); font-size: .9rem; }
++.kt-grid { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 268px), 1fr)); }
++@media (min-width: 900px) { .kt-tile--wide { grid-column: span 2; } }
++.kt-tile { position: relative; min-width: 0; display: grid; grid-template-rows: auto auto 1fr auto; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); overflow: hidden;
++ transition: border-color .15s, box-shadow .15s; scroll-margin-top: 72px; }
++.kt-tile:hover { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); box-shadow: 0 10px 30px -18px color-mix(in srgb, var(--room-strong) 60%, transparent); }
++.kt-tile:has(.kt-tile-a:focus-visible) { outline: 2px solid var(--room-strong); outline-offset: 2px; }
++/* The part itself, drawn small: never clickable, fixed children kept inside, a fade where it is cut */
++.kt-thumb { position: relative; height: 164px; padding: 16px; overflow: hidden; pointer-events: none; transform: translateZ(0); border-bottom: 1px solid var(--border, #26344d);
++ background: radial-gradient(color-mix(in srgb, var(--text-muted, #7386a3) 20%, transparent) 1px, transparent 1.2px) 0 0 / 14px 14px, var(--bg-primary, #0a0f1c);
++ -webkit-mask-image: linear-gradient(180deg, #000 72%, transparent); mask-image: linear-gradient(180deg, #000 72%, transparent); }
++/* The stage and the thumbnails set what a sample returns side by side with a gap, as a page would: inline parts
++ (buttons, badges, marks) sit in a row, and anything that is a block takes the whole width. */
++.kt-thumb, .kt-stage { display: flex; flex-wrap: wrap; align-items: flex-start; align-content: flex-start; gap: 10px; }
++.kt-thumb > :is(div, section, ul, ol, form, details, nav, header, table, figure, p, aside, dl, fieldset),
++.kt-stage > :is(div, section, ul, ol, form, details, nav, header, table, figure, p, aside, dl, fieldset) { flex: 1 1 100%; min-width: 0; }
++.kt-thumb > * { max-width: 100%; }
++.kt-thumb .ovk-head { position: static; }
++.kt-tile-t { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 10px; padding: 12px 14px 0; }
++.kt-tile-a { font-weight: 800; font-size: 1rem; color: var(--text-primary, #e6edf7); text-decoration: none; }
++.kt-tile-a::after { content: ''; position: absolute; inset: 0; z-index: 1; }
++.kt-tile-a:focus-visible { outline: none; }
++.kt-tile-t code { color: var(--text-muted, #7386a3); font-size: .76rem; }
++.kt-tile-l { margin: 6px 14px 0; color: var(--text-secondary, #96a7c2); font-size: .86rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.kt-tile-w { margin: 8px 14px 12px; color: var(--text-muted, #7386a3); font-size: .74rem; font-variant-numeric: tabular-nums; }
++
++.kt-helper-list { margin: 12px 0 0; display: grid; gap: 6px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); }
++.kt-helper-list > div { padding: 10px 12px; border: 1px dashed var(--border, #26344d); border-radius: 12px; }
++.kt-helper-list dt code { color: var(--room-strong); font-weight: 700; }
++.kt-helper-list dd { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); font-size: .86rem; line-height: 1.5; }
++.kt-more { margin: 30px 0 0; color: var(--text-muted, #7386a3); }
++.kt-more a, .kt-weight a { color: var(--room-strong); font-weight: 700; }
++
++/* A part's page */
++.kt-crumb { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; font-size: .86rem; color: var(--text-muted, #7386a3); }
++.kt-crumb a { color: var(--text-secondary, #96a7c2); text-decoration: none; font-weight: 700; }
++.kt-crumb a:hover { color: var(--room-strong); }
++.kt-facts { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
++.kt-facts li { padding: 4px 11px; border-radius: 999px; border: 1px solid var(--border, #26344d); font-size: .8rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.kt-facts li:first-child { border-color: color-mix(in srgb, var(--room-strong) 50%, var(--border, #26344d)); color: var(--room-strong); }
++/* The stage: the part for real, on a quiet dotted ground; a fixed part (the composer) stays inside it */
++.kt-stage { position: relative; margin-top: 22px; min-height: 180px; padding: clamp(18px, 3.4vw, 34px); border: 1px solid var(--border, #26344d); border-radius: 20px; transform: translateZ(0); overflow: hidden;
++ background: radial-gradient(color-mix(in srgb, var(--text-muted, #7386a3) 20%, transparent) 1px, transparent 1.2px) 0 0 / 16px 16px, var(--bg-primary, #0a0f1c); }
++.kt-stage:not(.kt-stage--wide) > * { max-width: 720px; }
++.kt-stage > dialog { flex: none; }
++.kt-stage[data-part="dock"], .kt-part[data-part="dock"] .kt-stage { min-height: 260px; }
++.kt-err { margin: 0; color: var(--text-muted, #7386a3); }
++.kt-code { margin-top: 30px; }
++.kt .kt-code h3 { margin-top: 16px; margin-bottom: 8px; }
++.kt-code .ovk-fold { margin-top: 16px; }
++.kt-weight { margin-top: 30px; }
++.kt-weight p { margin: 8px 0 0; max-width: 70ch; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
++.kt-bar { margin-top: 12px; height: 10px; max-width: 520px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); overflow: hidden; }
++.kt-bar i { display: block; height: 100%; min-width: 6px; border-radius: inherit; background: var(--room-strong); }
++.kt-pn { display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: stretch; margin-top: 36px; }
++.kt-pn a { display: grid; align-content: center; gap: 2px; min-height: 58px; padding: 10px 14px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729);
++ color: var(--text-primary, #e6edf7); font-weight: 800; text-decoration: none; }
++.kt-pn a small { color: var(--text-muted, #7386a3); font-weight: 700; font-size: .74rem; letter-spacing: .06em; text-transform: uppercase; }
++.kt-pn a[rel="next"] { text-align: right; }
++.kt-pn .kt-pn-all { place-items: center; color: var(--room-strong); }
++.kt-pn a:hover { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); }
++
++@media (max-width: 759px) {
++ /* A group is a rail a swipe wide, not a column of tiles */
++ .kt-grid { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 82%; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; }
++ .kt-tile { scroll-snap-align: start; }
++ .kt-use-grid { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 88%; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; align-items: stretch; }
++ .kt-use-card { scroll-snap-align: start; }
++ .kt-thumb { height: 148px; }
++ .kt-pn { grid-template-columns: 1fr 1fr; }
++ .kt-pn .kt-pn-all { grid-column: 1 / -1; grid-row: 2; }
++}
++@media (prefers-reduced-motion: reduce) { .kt-tile { transition: none; } }
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 7e93bff..728c3b4 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -31,6 +31,7 @@ function publicPages(themes = []) {
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Design', text: 'Browse OpenVibe\'s themes, try them on a preview, edit one with contrast checks, use it, and roll back to any earlier setting.' },
{ path: '/studio', changefreq: 'weekly', priority: 0.9, title: 'The theme studio', text: 'Try a theme on a preview, edit its variables with contrast checks, use it across the network, submit it to the gallery, and roll back.' },
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
++ { path: '/kit', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe Kit', text: 'Every part an OpenVibe page is made of, one function call each: drawn, with its code for the server and the page, and what its CSS weighs.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 68c36ac..f1d906b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -23,6 +23,7 @@ const views = require('../views');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
const componentsView = require('../views/components');
++const kitView = require('../views/kit');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const patternsView = require('../views/patterns');
@@@ -211,6 +212,12 @@ function createPageRoutes(ctx) {
});
// ── Components: the design system's components for web, Android and iOS (views/components.js) ──
++ // The OpenVibe Kit, part by part (plan L54 K3).
++ r.get('/kit', (req, res) => page(res, 200, kitView.kitIndex(), req.viewer));
++ r.get('/kit/:id', (req, res) => {
++ const html = kitView.kitPart(String(req.params.id || ''));
++ return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
++ });
r.get('/components', (req, res) => page(res, 200, componentsView.componentsPage({ id: String(req.query.c || ''), platform: String(req.query.p || 'web') }), req.viewer));
// ── Tokens: any public theme as code (views/tokens.js) ──
ddiff --git a/server/kit/parts.js b/server/kit/parts.js
nnew file mode 100644
iindex 0000000..cf3b0ef
---- /dev/null
++++ b/server/kit/parts.js
@@@ -0,0 +1,162 @@
++'use strict';
++/**
++ * The OpenVibe Kit, part by part (plan L54 K3): every builder in openvibe-shared/kit with what it is for, a sample, and
++ * what it weighs. Each sample is a line of code, and that same line is what the page draws: the snippet a reader copies
++ * can never drift from the picture beside it.
++ *
++ * PARTS [{ id, fn, name, group, line, code, web?, wide? }] code: an expression over `kit`
++ * GROUPS [[id, name, line]]
++ * HELPERS [[call, what it does]] the small functions with nothing to draw
++ * render(part) → the sample's HTML (or '' with the error, should a release change a builder)
++ *
++ * web: the part needs kit-web.js for its behaviour (menus that close, copy, sheets, Enter to send, drops). Which CSS
++ * file a part needs, and its weight, is measured from the CSS itself (server/kit/weight.js).
++ */
++const kit = require('openvibe-shared/kit');
++
++const GROUPS = [
++ ['actions', 'Actions', 'Buttons, menus and the small marks beside them.'],
++ ['content', 'Content', 'Cards, rails, pictures of people and what a page says when it has nothing.'],
++ ['layout', 'Layout', 'The shape of a page: its head, tabs, lists, sections and folds.'],
++ ['forms', 'Forms', 'Settings rows, search, the composer and the file drop.'],
++ ['data', 'Data', 'Numbers, steps, tables, charts, rankings and meters.'],
++ ['tools', 'Tools', 'One order for every tool page: what it does, the input, the answer, the family.'],
++ ['feedback', 'Feedback', 'Notices, receipts and the shape of a page while it loads.'],
++];
++
++const now = Date.UTC(2026, 10, 1, 14, 5);
++const PARTS = [
++ // ── Actions ──
++ { id: 'button', fn: 'button', group: 'actions', name: 'Button', line: 'One clear action: primary, quiet, ghost or danger. A link with href, a form button without.',
++ code: `kit.button({ label: 'Go live', href: '/go', icon: 'live' }) + kit.button({ label: 'Not now', kind: 'quiet', type: 'button' }) + kit.button({ label: 'Delete', kind: 'danger', type: 'button', small: true })` },
++ { id: 'icon-button', fn: 'iconButton', group: 'actions', name: 'Icon button', line: 'An action that is only an icon. Its label is what a screen reader says and what the tooltip shows.',
++ code: `kit.iconButton({ href: '/settings', icon: 'gear', label: 'Settings' }) + kit.iconButton({ href: '/invite', icon: 'invite', label: 'Invite people' })` },
++ { id: 'action', fn: 'action', group: 'actions', name: 'Action', line: 'A button that posts, with an optional confirm: leave, remove, stop.', web: true,
++ code: `kit.action({ action: '/s/night-owls/leave', icon: 'leave', label: 'Leave', kind: 'quiet', confirm: 'Leave Night owls?' })` },
++ { id: 'more', fn: 'more', group: 'actions', name: 'More menu', line: 'The rest of a row\'s actions, behind one button. Dangerous ones confirm.', web: true,
++ code: `kit.more({ label: 'More', items: [{ href: '/r/night-owls/edit', label: 'Edit the room', icon: 'pencil' }, { href: '/r/night-owls/invite', label: 'Invite', icon: 'invite' }, { action: '/r/night-owls/delete', label: 'Delete the room', icon: 'trash', danger: true, confirm: 'Delete Night owls for everyone?' }] })` },
++ { id: 'badge', fn: 'badge', group: 'actions', name: 'Badge', line: 'A word beside a title: live, new, a state. Five tones.',
++ code: `kit.badge('live', 'live') + kit.badge('new', 'accent') + kit.badge('paid', 'ok') + kit.badge('expiring', 'warn') + kit.badge('failed', 'bad')` },
++ { id: 'dot', fn: 'dot', group: 'actions', name: 'Unread dot', line: 'How many are new. Nothing at zero; 9+ past nine.',
++ code: `kit.dot(3) + kit.dot(42)` },
++
++ // ── Content ──
++ { id: 'card', fn: 'card', group: 'content', name: 'Card', line: 'A stream, a clip, a room or a place: picture first, the title, who, and one line.',
++ code: `kit.card({ href: '/goosely', title: 'Seattle at night, chat picks the route', type: 'stream', live: true, who: 'goosely', count: [1204, 'watching'], sub: 'IRL · Capitol Hill' })` },
++ { id: 'rail', fn: 'rail', group: 'content', name: 'Rail', line: 'A row of cards that scrolls sideways on a phone and wraps on a computer when asked. A long list never makes a long page.', wide: true,
++ code: `kit.rail({ id: 'now', title: 'Live now', more: { href: '/live', label: 'See all' }, items: [kit.card({ href: '/goosely', title: 'Seattle at night', live: true, who: 'goosely', type: 'stream' }), kit.card({ href: '/surfwax', title: 'Surf race, heat 3', live: true, who: 'surfwax', type: 'stream' }), kit.card({ href: '/v/91', title: 'Building a robot arm, day 12', who: 'ana', type: 'video', count: [880, 'views'] }), kit.card({ href: '/v/92', title: 'Lo-fi and a rainy window', who: 'kai', type: 'video' })] })` },
++ { id: 'mark', fn: 'mark', group: 'content', name: 'Mark', line: 'A person or a place: their picture, or up to two letters on a colour that comes from the name.',
++ code: `kit.mark({ name: 'Ana Lima' }) + kit.mark({ name: 'Night owls' }) + kit.mark({ name: 'Pike Place', square: true }) + kit.mark({ name: 'kai', size: 48 })` },
++ { id: 'empty', fn: 'empty', group: 'content', name: 'Empty state', line: 'What a page says when there is nothing yet, and the one thing to do about it.',
++ code: `kit.empty({ icon: 'chat', title: 'No rooms yet', text: 'Start the first one: public, or private to the people you invite.', action: { href: '/rooms/new', label: 'Start a room' } })` },
++ { id: 'snippet', fn: 'snippet', group: 'content', name: 'Snippet', line: 'Code to copy, with a copy button in the corner.', web: true,
++ code: `kit.snippet({ id: 'embed', code: '<iframe src="https://openvibe.live/embed/goosely" allow="autoplay; fullscreen"></iframe>' })` },
++ { id: 'sheet', fn: 'sheet', group: 'content', name: 'Sheet', line: 'More choices without leaving the page: up from the bottom on a phone, a dialog on a computer.', web: true,
++ code: `kit.sheetButton({ sheet: 'filters', label: 'Filters', iconOnly: false }) + kit.sheet({ id: 'filters', title: 'Filters', body: kit.chips({ name: 'kind', label: 'Show', options: [['live', 'Live', true], ['clips', 'Clips'], ['rooms', 'Rooms']] }) })` },
++
++ // ── Layout ──
++ { id: 'head', fn: 'head', group: 'layout', name: 'Head', line: 'The top of an app page: back, the title, one muted line, icon actions and the tabs. It sticks under the navbar.', wide: true,
++ code: `kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' }, level: 2, sticky: false, actions: [kit.iconButton({ href: '/help', icon: 'help', label: 'Help' })], tabs: kit.tabs({ base: '/kit/head', items: [{ key: 'look', label: 'Look' }, { key: 'sound', label: 'Sound' }, { key: 'privacy', label: 'Privacy' }], active: 'look' }) })` },
++ { id: 'tabs', fn: 'tabs', group: 'layout', name: 'Tabs', line: 'Links, so they work without JavaScript. For settings and explainers, never to hide content.',
++ code: `kit.tabs({ base: '/kit/tabs', items: [{ key: 'look', label: 'Look' }, { key: 'sound', label: 'Sound', count: 2 }, { key: 'privacy', label: 'Privacy' }], active: 'look' })` },
++ { id: 'list', fn: 'list', group: 'layout', name: 'List', line: 'Rows under a title, with a link to all of them.',
++ code: `kit.list([kit.row({ href: '/r/night-owls', mark: kit.mark({ name: 'Night owls' }), title: 'Night owls', sub: 'ana: anyone up for the surf race?', meta: '14:05', unread: 2 }), kit.row({ href: '/r/robots', mark: kit.mark({ name: 'Robot arms' }), title: 'Robot arms', sub: 'kai sent a clip', meta: 'Yesterday' })], { title: 'Your rooms', more: { href: '/rooms', label: 'All' } })` },
++ { id: 'row', fn: 'row', group: 'layout', name: 'Row', line: 'A mark, the title with a badge and the time on one line, one muted line under it. The whole row is the link.',
++ code: `kit.list([kit.row({ href: '/@goosely', mark: kit.mark({ name: 'goosely' }), title: 'goosely', badge: kit.badge('live', 'live'), meta: '2 h', sub: 'Seattle at night, chat picks the route' })])` },
++ { id: 'section', fn: 'section', group: 'layout', name: 'Section', line: 'A titled block of a page, with its count and one quiet note.',
++ code: `kit.section({ title: 'Members', count: 12, note: 'Who can do what', body: kit.list([kit.row({ mark: kit.mark({ name: 'Ana Lima' }), title: 'Ana Lima', sub: 'Owner' })], { flush: true }) })` },
++ { id: 'fold', fn: 'fold', group: 'layout', name: 'Fold', line: 'A card that opens: setup, extras, the danger zone. It says what is inside and how much.',
++ code: `kit.fold({ title: 'Put this room on your site', meta: 'two ways', body: kit.snippet({ code: '<iframe src="https://openvibe.chat/r/night-owls/embed"></iframe>' }) }) + kit.fold({ title: 'Delete the room', tone: 'danger', body: kit.notice({ kind: 'warn', text: 'Everyone loses the history. This cannot be undone.' }) })` },
++ { id: 'sidebar', fn: 'sidebar', group: 'layout', name: 'Sidebar', line: 'Navigation down the left on a computer, a drawer on a phone.', web: true, wide: true,
++ code: `kit.withSidebar({ sidebar: kit.sidebar({ id: 'kit-demo-side', label: 'Rooms', nav: [{ href: '/kit/sidebar', label: 'Night owls', icon: 'chat', active: true }, { href: '/kit/sidebar', label: 'Robot arms', icon: 'chat' }, { href: '/kit/sidebar', label: 'Surf crew', icon: 'chat' }] }), body: kit.empty({ title: 'Night owls', text: 'The room opens here.' }) })` },
++ { id: 'page', fn: 'page', group: 'layout', name: 'Page', line: 'The whole shape of an app page in one call; the side column sits beside the content from 1024 px, under it below.', wide: true,
++ code: `kit.page({ id: 'kit-demo-page', head: kit.head({ title: 'Night owls', sub: '38 here', level: 2, sticky: false }), body: kit.list([kit.row({ mark: kit.mark({ name: 'ana' }), title: 'ana', sub: 'anyone up for the surf race?', meta: '14:05' })], { flush: true }), side: kit.stats([[38, 'here now'], [412, 'members']]) })` },
++
++ // ── Forms ──
++ { id: 'find', fn: 'find', group: 'forms', name: 'Find', line: 'A search, or "add by name": a pill with an icon, the field and a round button.',
++ code: `kit.find({ action: '/search', name: 'q', placeholder: 'Search streams, people and rooms', label: 'Search' })` },
++ { id: 'dock', fn: 'dock', group: 'forms', name: 'Composer', line: 'The one thing you type, pinned at the bottom. Enter sends; it grows as you write.', web: true,
++ code: `kit.dock({ action: '/r/night-owls/messages', placeholder: 'Message Night owls', id: 'kit-demo-dock' })` },
++ { id: 'switch-row', fn: 'switchRow', group: 'forms', name: 'Switch row', line: 'An on/off setting: the label and a hint on the left, the switch on the right.',
++ code: `'<form class="ovk-form">' + kit.switchRow({ name: 'live_alerts', label: 'Tell me when people I follow go live', hint: 'In the bell and as a push', on: true }) + '</form>'` },
++ { id: 'range-row', fn: 'rangeRow', group: 'forms', name: 'Range row', line: 'An amount: the label, the slider and its value.',
++ code: `'<form class="ovk-form">' + kit.rangeRow({ name: 'volume', label: 'Alert volume', value: 60 }) + '</form>'` },
++ { id: 'select-row', fn: 'selectRow', group: 'forms', name: 'Select row', line: 'One choice of several, as a row.',
++ code: `'<form class="ovk-form">' + kit.selectRow({ name: 'quality', label: 'Video quality', hint: 'Auto follows your connection', options: [['auto', 'Auto'], ['1080', '1080p'], ['720', '720p']], value: 'auto' }) + '</form>'` },
++ { id: 'seg', fn: 'seg', group: 'forms', name: 'Segmented', line: 'Two to four choices you can see at once.',
++ code: `kit.seg({ name: 'view', label: 'View', options: [['grid', 'Grid'], ['list', 'List'], ['map', 'Map']], value: 'grid' })` },
++ { id: 'chips', fn: 'chips', group: 'forms', name: 'Chips', line: 'Filters you can turn on and off, or one of a few.',
++ code: `kit.chips({ name: 'kind', label: 'Show', options: [['live', 'Live', true], ['clips', 'Clips', true], ['rooms', 'Rooms'], ['games', 'Games']] })` },
++ { id: 'save-bar', fn: 'saveBar', group: 'forms', name: 'Save bar', line: 'The save button of a settings form, kept in reach above the phone\'s app bar.',
++ code: `kit.saveBar({ label: 'Save settings' })` },
++ { id: 'drop', fn: 'drop', group: 'forms', name: 'Drop', line: 'Where a file goes: a real file input, plus drag and drop, paste and the phone\'s camera.', web: true,
++ code: `kit.drop({ name: 'photo', accept: 'image/*', label: 'Choose a photo', hint: 'JPEG, PNG or WebP, up to 20 MB', camera: true, id: 'kit-demo-drop' })` },
++
++ // ── Data ──
++ { id: 'stats', fn: 'stats', group: 'data', name: 'Numbers', line: 'A strip of numbers that matter. Zero and unknown are left out; it scrolls sideways on a phone.',
++ code: `kit.stats([[1204, 'watching now'], [48, 'people chatting'], [12, 'streams live', '/live']])` },
++ { id: 'num', fn: 'num', group: 'data', name: 'Number', line: 'A number the way a reader wants it: money, compact, percent, time. Missing is "–", never 0.',
++ code: `[kit.num(12.5, 'money'), kit.num(0.027, 'money'), kit.num(1234567, 'compact'), kit.num(0.375, 'percent'), kit.num(2400, 'ms'), kit.num(null, 'int')].join(' · ')` },
++ { id: 'steps', fn: 'steps', group: 'data', name: 'Steps', line: 'A flow of a few steps. Done steps link back; later ones never link ahead.',
++ code: `kit.steps({ current: 1, items: [{ label: 'Opt in', href: '/merchant' }, { label: 'Verify the domain' }, { label: 'Choose a plan' }, { label: 'Live' }] })` },
++ { id: 'table', fn: 'table', group: 'data', name: 'Table', line: 'Plans, prices, endpoints: anything compared across columns. On a phone each row becomes a card.', wide: true,
++ code: `kit.table({ caption: 'Plans', columns: [{ key: 'plan', label: 'Plan', primary: true }, { key: 'price', label: 'Price', fmt: 'money' }, { key: 'streams', label: 'Streams', fmt: 'int' }, { key: 'clips', label: 'Clips kept' }], rows: [{ plan: 'Free', price: 0, streams: 1, clips: '30 days' }, { plan: 'Creator', price: 6, streams: 3, clips: '1 year' }, { plan: 'Studio', price: 18, streams: 10, clips: 'Forever' }] })` },
++ { id: 'chart', fn: 'chart', group: 'data', name: 'Chart', line: 'Line, area, bar or scatter. Text stays its real size at every width, and a table with the same numbers sits behind it.', web: true, wide: true,
++ code: `kit.chart({ type: 'area', title: 'Viewers, last 7 days', y: { fmt: 'compact' }, x: { time: true }, series: [{ name: 'Viewers', points: [[${now - 6 * 864e5}, 820], [${now - 5 * 864e5}, 1100], [${now - 4 * 864e5}, 960], [${now - 3 * 864e5}, 1480], [${now - 2 * 864e5}, 1320], [${now - 864e5}, 1710], [${now}, 1204]] }] })` },
++ { id: 'rank', fn: 'rank', group: 'data', name: 'Ranking', line: 'A leaderboard: the place, the name, the value with its interval, the change, a bar in proportion.',
++ code: `kit.rank({ base: 900, fmt: 'int', items: [{ name: 'claude-opus-5-5', value: 1088, interval: 14, delta: 2 }, { name: 'gemini-3-pro', value: 1051, interval: 18, delta: -1 }, { name: 'gpt-5', value: 1032, interval: 16 }, { name: 'deepseek-v4', value: 998, interval: 21, delta: 1 }] })` },
++ { id: 'meter', fn: 'meter', group: 'data', name: 'Meter', line: 'How full something is. With tone auto, a limit turns amber from 75 % and red from 90 %.',
++ code: `kit.meter({ value: 62, max: 100, label: 'Storage', text: '6.2 of 10 GB' }) + kit.meter({ value: 93, max: 100, label: 'Uploads today', text: '93 of 100', tone: 'auto' })` },
++
++ // ── Tools ──
++ { id: 'tool-head', fn: 'toolHead', group: 'tools', name: 'Tool head', line: 'What the tool does, in its colour, with the facts that matter (free, no sign-in, what is kept).',
++ code: `kit.toolHead({ icon: 'image', hue: 280, family: 'Images', crumb: { href: '/tools', label: 'Tools' }, title: 'Shrink an image', task: 'Make a picture smaller without it looking worse.', facts: ['Free', 'No sign-in', 'Nothing kept'], level: 2 })` },
++ { id: 'tool-input', fn: 'toolInput', group: 'tools', name: 'Tool input', line: 'The one field a tool needs, its button, and examples to try.',
++ code: `kit.toolInput({ name: 'domain', label: 'A domain', placeholder: 'openvibe.live', button: 'Look it up', icon: 'search', tries: { items: [{ value: 'openvibe.live' }, { value: 'example.com' }] } })` },
++ { id: 'answer', fn: 'answer', group: 'tools', name: 'Answer', line: 'The answer first, then its rows, then what to do next.',
++ code: `kit.answer({ title: 'openvibe.live is reachable', badge: kit.badge('ok', 'ok'), rows: [['Address', '104.21.32.1'], ['Answered in', '38 ms'], ['Certificate', 'Valid until Jan 30, 2027']], next: [{ href: '/tools/dns', label: 'See every DNS record' }] })` },
++ { id: 'family', fn: 'family', group: 'tools', name: 'Family', line: 'The tools next to this one.',
++ code: `kit.family({ title: 'More image tools', more: { href: '/tools/images', label: 'All 14' }, items: [{ href: '/tools/crop', label: 'Crop', icon: 'image' }, { href: '/tools/convert', label: 'Convert', icon: 'swap' }, { href: '/tools/remove-bg', label: 'Remove the background', icon: 'spark' }] })` },
++ { id: 'tool', fn: 'tool', group: 'tools', name: 'Tool frame', line: 'One order for every tool page: head, input, answer, family, folds, related.', wide: true,
++ code: `kit.tool({ head: kit.toolHead({ icon: 'search', hue: 200, title: 'DNS lookup', task: 'Every record for a domain.', facts: ['Free'], level: 2 }), input: kit.toolInput({ name: 'd', label: 'A domain', placeholder: 'openvibe.live', button: 'Look up' }), answer: kit.answer({ title: '4 records', rows: [['A', '104.21.32.1'], ['AAAA', '2606:4700::6810:2001']] }), family: kit.family({ title: 'More network tools', items: [{ href: '/tools/ping', label: 'Ping', icon: 'wave' }] }) })` },
++ { id: 'tries', fn: 'tries', group: 'tools', name: 'Tries', line: 'Examples a person can run with one tap.',
++ code: `kit.tries({ name: 'q', label: 'Try', items: [{ value: 'openvibe.live' }, { value: 'openvibe.chat' }, { value: 'example.com' }] })` },
++
++ // ── Feedback ──
++ { id: 'notice', fn: 'notice', group: 'feedback', name: 'Notice', line: 'A sentence about what just happened: ok, info, warn or error. Errors are announced.',
++ code: `kit.notice({ kind: 'ok', text: 'Saved. Every OpenVibe site follows.' }) + kit.notice({ kind: 'warn', text: 'Your stream key expires in 3 days.' }) + kit.notice({ kind: 'error', text: 'That name is taken. Try another.' })` },
++ { id: 'receipt', fn: 'receipt', group: 'feedback', name: 'Receipt', line: 'A result and what it took: the answer first, then one quiet line of facts.',
++ code: `kit.receipt({ title: 'Clip saved', body: 'The last 30 seconds of goosely\\'s stream are in your clips.', facts: [['Length', '0:30'], ['Size', '4.2 MB'], ['Kept', 'until you remove it']], actions: [kit.button({ href: '/clips', label: 'Open your clips', kind: 'quiet', small: true })] })` },
++ { id: 'skeleton', fn: 'skeleton', group: 'feedback', name: 'Skeleton', line: 'The shape of what is coming, so nothing jumps when it arrives. It never spins.',
++ code: `kit.skeleton({ kind: 'row', count: 3 })` },
++];
++
++const HELPERS = [
++ ['kit.esc(text)', 'Escapes text for HTML. Every builder already does it for its text arguments.'],
++ ['kit.url(href)', 'Keeps http(s) and same-site links; anything else becomes #.'],
++ ['kit.icon(name)', 'One of kit.ICONS as inline SVG, in the text\'s colour. The full set is at /icons.'],
++ ['kit.when(ts)', '{ iso, text }: 14:05 today, Yesterday, a weekday this week, Oct 20 this year, else the date.'],
++ ['kit.hueOf(key)', 'The colour a mark wears for a name: the same name, the same hue, everywhere.'],
++ ['kit.tabOf(query, keys)', 'The open tab: a known key from the query, else the first.'],
++ ['kit.enhance(el)', 'After a page inserts markup itself: wires copy buttons, menus, sheets and the composer.'],
++];
++
++const byId = new Map(PARTS.map((p) => [p.id, p]));
++const cache = new Map();
++
++/** The sample's HTML: the part's own code line run against the installed kit. */
++function render(part) {
++ if (cache.has(part.id)) return cache.get(part.id);
++ let html = '';
++ try {
++ // The code is this file's own constant, never anything a request sent.
++ html = String(new Function('kit', `return (${part.code});`)(kit) || '');
++ } catch (err) {
++ html = '';
++ part.error = (err && err.message) || String(err);
++ }
++ cache.set(part.id, html);
++ return html;
++}
++
++module.exports = { PARTS, GROUPS, HELPERS, byId, render };
ddiff --git a/server/kit/weight.js b/server/kit/weight.js
nnew file mode 100644
iindex 0000000..72c86f6
---- /dev/null
++++ b/server/kit/weight.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * What a kit part weighs (plan L54 K3), measured from the CSS this release serves, never typed by hand:
++ *
++ * files() [{ file, bytes, brotli }] kit.css, kit-web.js, kit-data.css, kit-data-web.js, kit.js
++ * ofPart(html) { classes, rules, bytes, brotli, files: ['kit.css' | 'kit-data.css'] }
++ * the rules (in either stylesheet) whose selector names only ovk- classes the part's own markup uses
++ *
++ * The stylesheets are split into rules by a small brace walker (comments dropped; a rule inside @media counts once,
++ * as itself). Brotli at quality 11, as the CDN sends them.
++ */
++const fs = require('fs');
++const path = require('path');
++const zlib = require('zlib');
++
++const SHARED = path.dirname(require.resolve('openvibe-shared/kit'));
++const FILES = ['kit.css', 'kit-web.js', 'kit-data.css', 'kit-data-web.js', 'kit.js'];
++const br = (text) => zlib.brotliCompressSync(Buffer.from(String(text)), { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
++
++/** Every style rule of a stylesheet: { selector, text }. */
++function rulesOf(css) {
++ const src = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
++ const out = [];
++ let i = 0;
++ const walk = (end) => {
++ while (i < src.length) {
++ if (src[i] === '}') { i++; return; }
++ const open = src.indexOf('{', i);
++ const close = src.indexOf('}', i);
++ if (open < 0 || (close >= 0 && close < open)) { i = close < 0 ? src.length : close + 1; return; }
++ const head = src.slice(i, open).trim();
++ i = open + 1;
++ if (head.startsWith('@')) {
++ if (/^@(media|supports|container|layer)\b/.test(head)) { walk(); continue; }
++ let depth = 1; // @keyframes, @font-face: skip the block whole
++ while (i < src.length && depth) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; }
++ continue;
++ }
++ const stop = src.indexOf('}', i);
++ const body = src.slice(i, stop < 0 ? src.length : stop);
++ i = stop < 0 ? src.length : stop + 1;
++ if (head) out.push({ selector: head, text: `${head}{${body.trim()}}` });
++ }
++ return end;
++ };
++ walk();
++ return out;
++}
++
++let loaded = null;
++function load() {
++ if (loaded) return loaded;
++ const read = (f) => { try { return fs.readFileSync(path.join(SHARED, f), 'utf8'); } catch { return ''; } };
++ const text = Object.fromEntries(FILES.map((f) => [f, read(f)]));
++ loaded = {
++ files: FILES.filter((f) => text[f]).map((f) => ({ file: f, bytes: Buffer.byteLength(text[f]), brotli: br(text[f]) })),
++ rules: ['kit.css', 'kit-data.css'].flatMap((f) => rulesOf(text[f]).map((r) => ({ ...r, file: f }))),
++ };
++ return loaded;
++}
++
++const files = () => load().files;
++const partCache = new Map();
++
++/** The CSS a part's markup reaches: the rules made for its own ovk- classes. */
++function ofPart(html) {
++ const key = String(html || '');
++ if (partCache.has(key)) return partCache.get(key);
++ const classes = [...new Set((key.match(/\bovk-[a-z0-9-]+/g) || []))];
++ const named = new Set(classes);
++ // A rule is the part's when one of its selectors names only classes the part itself uses: `.ovk-btn` is the
++ // button's, `.ovk-tool .ovk-btn` is the tool frame's.
++ const mine = (sel) => { const cs = sel.match(/\.ovk-[a-z0-9-]+/g) || []; return cs.length > 0 && cs.every((c) => named.has(c.slice(1))); };
++ const rules = load().rules.filter((r) => r.selector.split(',').some(mine));
++ const css = rules.map((r) => r.text).join('');
++ const out = {
++ classes: classes.length,
++ rules: rules.length,
++ bytes: Buffer.byteLength(css),
++ brotli: css ? br(css) : 0,
++ files: [...new Set(rules.map((r) => r.file))],
++ };
++ partCache.set(key, out);
++ return out;
++}
++
++const kb = (n) => (n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`);
++
++module.exports = { files, ofPart, rulesOf, kb };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 2b779d7..9a2809c 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -33,6 +33,7 @@ const NAV = [
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
++ { label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
@@@ -46,7 +47,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
--function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt, shared = [] } = {}) {
++function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
@@@ -81,6 +82,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
<link rel="stylesheet" href="${esc(asset('css/design.css'))}">
++${(sharedStyles || []).map((file) => `<link rel="stylesheet" href="${esc(ovServe.url(file))}">`).join('\n')}
${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
ddiff --git a/server/views/kit.js b/server/views/kit.js
nnew file mode 100644
iindex 0000000..eaf0c5c
---- /dev/null
++++ b/server/views/kit.js
@@@ -0,0 +1,166 @@
++'use strict';
++/**
++ * openvibe.design/kit (plan L54 K3): the OpenVibe Kit, part by part. The index shows every part drawn small, by group,
++ * with what its CSS weighs; a part's page draws it for real, with the line that made it, the same thing for a page
++ * that draws itself (the CDN), the HTML it returns, its weight against the whole kit, and where it is also native.
++ *
++ * kitIndex() /kit
++ * kitPart(id) /kit/:id (null when there is no such part)
++ */
++const kit = require('openvibe-shared/kit');
++const components = require('openvibe-shared/components');
++const { layout, esc } = require('../render/layout');
++const { PARTS, GROUPS, HELPERS, byId, render } = require('../kit/parts');
++const weight = require('../kit/weight');
++
++const CDN = 'https://openvibe.network/shared';
++const VERSION = (() => { try { return require('openvibe-shared/package.json').version; } catch { return ''; } })();
++const NATIVE = new Set((() => { const s = components && components.SPECS; return Array.isArray(s) ? s.map((c) => c && c.id) : Object.keys(s || {}); })().filter(Boolean));
++
++/** A sample drawn in a small box: never interactive, and its ids renamed so nothing on the page collides. */
++// (aria-current too: kit-web.js keeps an open tab in view, and a thumbnail's tab is never the reader's.)
++const still = (html) => String(html).replace(/\s(id|for|aria-controls|aria-labelledby|aria-describedby|aria-current|form|list)="/g, ' data-k$1="').replace(/<(\/?)main\b/g, '<$1div');
++/** The sample on its own page: a <main> inside the page's main would be two. */
++const staged = (part, html) => (part.id === 'page' ? String(html).replace(/<(\/?)main\b/g, '<$1div') : html);
++
++/** The code line, broken where a reader would: between parts that are added, and between a long call's options. */
++function pretty(code) {
++ const pieces = [];
++ let depth = 0, quote = null, start = 0;
++ for (let i = 0; i < code.length; i++) {
++ const ch = code[i];
++ if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; continue; }
++ if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; }
++ if ('([{'.includes(ch)) depth++;
++ else if (')]}'.includes(ch)) depth--;
++ else if (depth === 0 && code.startsWith(' + ', i)) { pieces.push(code.slice(start, i)); start = i + 3; i += 2; }
++ }
++ pieces.push(code.slice(start));
++ const split = (line) => {
++ if (line.length <= 96) return line;
++ let d = 0, q = null, out = '';
++ for (let i = 0; i < line.length; i++) {
++ const ch = line[i];
++ out += ch;
++ if (q) { if (ch === '\\') { out += line[++i]; } else if (ch === q) q = null; continue; }
++ if (ch === "'" || ch === '"' || ch === '`') { q = ch; continue; }
++ if ('([{'.includes(ch)) { d++; if (d === 2 && ch === '{') out += '\n '; }
++ else if (')]}'.includes(ch)) d--;
++ else if (ch === ',' && d === 2) { out += '\n '; if (line[i + 1] === ' ') i++; }
++ }
++ return out.replace(/\{\n {3}\s*/g, '{\n ').replace(/\n {3}(?=\S)/g, '\n ').replace(/ \}\)$/, '\n})')
++ // An option that is a long list of objects: one object a line.
++ .split('\n').map((l) => (l.length > 100 && /: \[\{/.test(l) ? l.replace(/: \[\{ /, ': [\n { ').replace(/\}, \{ /g, '},\n { ').replace(/ \}\](,?)$/, ' },\n ]$1')
++ : l.length > 100 && /: \[kit\./.test(l) ? l.replace(/: \[kit\./, ': [\n kit.').replace(/\), kit\./g, '),\n kit.').replace(/\)\](,?)$/, '),\n ]$1') : l)).join('\n');
++ };
++ // A space put between two parts only to draw them apart is not part of the lesson.
++ return pieces.map((p) => p.trim()).filter((p) => p !== "' '").map(split).join('\n + ');
++}
++
++const fileChip = (f) => `<li class="kt-file"><code>${esc(f.file)}</code><span>${weight.kb(f.brotli)}</span></li>`;
++
++function useCards() {
++ const files = weight.files();
++ const server = `// npm i https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v${VERSION}
++const kit = require('openvibe-shared/kit');
++
++res.send(kit.head({ title: 'Rooms' })
++ + kit.list(rooms.map((r) => kit.row({ href: \`/r/\${r.slug}\`, title: r.name, sub: r.topic }))));`;
++ const page = `<link rel="stylesheet" href="${CDN}/kit.css">
++<script src="${CDN}/kit.js"></script>
++<script src="${CDN}/kit-web.js" defer></script>
++
++<script>
++ const box = document.querySelector('#rooms');
++ box.innerHTML = OpenVibeKit.list(rooms.map((r) => OpenVibeKit.row({ title: r.name, sub: r.topic })));
++ OpenVibeKit.enhance(box);
++</script>`;
++ return `<section class="kt-use" aria-labelledby="kt-use-h"><h2 id="kt-use-h">Use it</h2>
++<div class="kt-use-grid">
++<div class="kt-use-card"><h3>On your server</h3><p>Every builder returns a string of HTML with its text escaped. The page is whole before any script runs.</p>${kit.snippet({ id: 'kt-use-server', code: server })}</div>
++<div class="kt-use-card"><h3>In a page that draws itself</h3><p>The same builders on <code>window.OpenVibeKit</code>, from the CDN with open CORS. Call <code>enhance</code> after adding markup.</p>${kit.snippet({ id: 'kt-use-page', code: page })}</div>
++<div class="kt-use-card"><h3>Copy the HTML</h3><p>Each part's page prints the HTML its call returns. Keep <code>kit.css</code> and it looks the same; without OpenVibe's theme, set <code>--bg-*</code>, <code>--text-*</code> and <code>--accent</code> yourself.</p>
++<ul class="kt-files" aria-label="The files, brotli as served">${files.map(fileChip).join('')}</ul></div>
++</div></section>`;
++}
++
++function kitIndex() {
++ const tile = (p) => {
++ const html = render(p);
++ const w = weight.ofPart(html);
++ return `<li class="kt-tile${p.wide ? ' kt-tile--wide' : ''}" id="kt-${esc(p.id)}">
++<div class="kt-thumb" aria-hidden="true" inert>${still(html)}</div>
++<div class="kt-tile-t"><a class="kt-tile-a" href="/kit/${esc(p.id)}">${esc(p.name)}</a><code>kit.${esc(p.fn)}()</code></div>
++<p class="kt-tile-l">${esc(p.line)}</p>
++<p class="kt-tile-w">${w.bytes ? `${weight.kb(w.brotli)} of CSS` : 'No CSS of its own'}${p.web ? ' · uses kit-web.js' : ''}</p></li>`;
++ };
++ const groups = GROUPS.map(([id, name, line]) => {
++ const parts = PARTS.filter((p) => p.group === id);
++ return `<section class="kt-group" id="kt-g-${esc(id)}" aria-labelledby="kt-g-${esc(id)}-h"><div class="kt-group-h"><h2 id="kt-g-${esc(id)}-h">${esc(name)}</h2><span>${parts.length}</span></div><p class="kt-group-l">${esc(line)}</p>
++<ul class="kt-grid">${parts.map(tile).join('')}</ul></section>`;
++ }).join('');
++ const nav = `<nav class="kt-gnav" aria-label="Groups">${GROUPS.map(([id, name]) => `<a href="#kt-g-${esc(id)}">${esc(name)} <span>${PARTS.filter((p) => p.group === id).length}</span></a>`).join('')}</nav>`;
++ const helpers = `<section class="kt-group kt-helpers" id="kt-g-helpers" aria-labelledby="kt-helpers-h"><div class="kt-group-h"><h2 id="kt-helpers-h">Helpers</h2><span>${HELPERS.length}</span></div><p class="kt-group-l">Small functions with nothing to draw.</p>
++<dl class="kt-helper-list">${HELPERS.map(([call, what]) => `<div><dt><code>${esc(call)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>`;
++ const css = weight.files().filter((f) => /\.css$/.test(f.file)).reduce((a, f) => a + f.brotli, 0);
++ const body = `<div class="kt" id="kt-index">
++<header class="kt-hero"><p class="kt-kicker">The OpenVibe Kit${VERSION ? ` · v${esc(VERSION)}` : ''} · MIT</p>
++<h1>Every piece an OpenVibe page is made of</h1>
++<p class="kt-lede">${PARTS.length} parts, each one function call that returns plain HTML: escaped, phone first, on the site's own theme. Pages work without JavaScript; one small file adds the menus, copy buttons and sheets. All of it is ${weight.kb(css)} of CSS.</p></header>
++${nav}
++${useCards()}
++${groups}
++${helpers}
++<p class="kt-more">Eleven parts are also drawn natively, in Jetpack Compose and SwiftUI: see <a href="/components">Components</a>. The icons are at <a href="/icons">Icons</a>.</p>
++</div>`;
++ return layout({
++ title: 'The OpenVibe Kit',
++ description: `${PARTS.length} UI parts as one function call each: plain, escaped HTML on any theme, phone first, with what each weighs. MIT, from npm or the CDN.`,
++ path: '/kit', active: '/kit', body: `<div class="ds-wrap">${body}</div>`,
++ styles: ['css/kit-pages.css'], sharedStyles: ['kit-data.css'], shared: ['kit-data-web.js'],
++ });
++}
++
++function kitPart(id) {
++ const part = byId.get(String(id || ''));
++ if (!part) return null;
++ const html = render(part);
++ const w = weight.ofPart(html);
++ const total = weight.files().filter((f) => /\.css$/.test(f.file)).reduce((a, f) => a + f.brotli, 0);
++ const i = PARTS.indexOf(part);
++ const prev = PARTS[(i - 1 + PARTS.length) % PARTS.length];
++ const next = PARTS[(i + 1) % PARTS.length];
++ const group = GROUPS.find(([g]) => g === part.group) || [part.group, part.group];
++ const needsData = w.files.includes('kit-data.css');
++ const cdn = [`<link rel="stylesheet" href="${CDN}/kit.css">`, needsData ? `<link rel="stylesheet" href="${CDN}/kit-data.css">` : null,
++ `<script src="${CDN}/kit.js"></script>`, part.web || needsData ? `<script src="${CDN}/kit-web.js" defer></script>` : null,
++ needsData && /chart|drop/.test(part.id) ? `<script src="${CDN}/kit-data-web.js" defer></script>` : null].filter(Boolean).join('\n');
++ const inPage = `${cdn}\n\n<script>\n const box = document.querySelector('#here');\n box.innerHTML = ${pretty(part.code).replace(/\bkit\./g, 'OpenVibeKit.').split('\n').join('\n ')};\n OpenVibeKit.enhance(box);\n</script>`;
++ const onServer = `const kit = require('openvibe-shared/kit');\n\nconst html = ${pretty(part.code).split('\n').join('\n ')};`;
++ const share = total ? Math.max(1, Math.round((w.brotli / total) * 100)) : 0;
++ const native = NATIVE.has(part.id) || NATIVE.has(part.fn);
++ const body = `<div class="kt kt-part" id="kt-part" data-part="${esc(part.id)}">
++<nav class="kt-crumb" aria-label="Breadcrumb"><a href="/kit">The Kit</a><span aria-hidden="true">/</span><a href="/kit#kt-g-${esc(group[0])}">${esc(group[1])}</a></nav>
++<header class="kt-part-h"><h1>${esc(part.name)}</h1><p class="kt-lede">${esc(part.line)}</p>
++<ul class="kt-facts"><li><code>kit.${esc(part.fn)}()</code></li><li>${w.bytes ? `${weight.kb(w.brotli)} of CSS · ${w.rules} rule${w.rules === 1 ? '' : 's'}` : 'No CSS of its own'}</li>${w.files.map((f) => `<li>${esc(f)}</li>`).join('')}${part.web ? '<li>kit-web.js for its behaviour</li>' : '<li>No script needed</li>'}</ul></header>
++<section class="kt-stage${part.wide ? ' kt-stage--wide' : ''}" aria-label="${esc(part.name)}, drawn by this release">${part.error ? `<p class="kt-err">This release cannot draw the sample: ${esc(part.error)}</p>` : staged(part, html)}</section>
++<section class="kt-code" aria-labelledby="kt-code-h"><h2 id="kt-code-h">Use it</h2>
++<h3>On your server</h3>${kit.snippet({ id: 'kt-server', code: onServer })}
++<h3>In a page that draws itself</h3>${kit.snippet({ id: 'kt-page', code: inPage })}
++${kit.fold({ id: 'kt-html', title: 'The HTML it returns', meta: weight.kb(Buffer.byteLength(html)), body: kit.snippet({ id: 'kt-html-code', code: html }) })}
++</section>
++<section class="kt-weight" aria-labelledby="kt-weight-h"><h2 id="kt-weight-h">What it weighs</h2>
++<p>${w.bytes ? `Its own rules are ${weight.kb(w.bytes)}, ${weight.kb(w.brotli)} as sent: ${share}% of the kit's CSS. A page that draws it pays for the whole stylesheet once, and every other part comes free.` : 'Nothing of its own: it is text, styled by whatever holds it.'}</p>
++${w.bytes ? `<div class="kt-bar" role="img" aria-label="${share} percent of the kit's CSS"><i style="width:${Math.min(100, share)}%"></i></div>` : ''}
++${native ? `<p>It is also drawn natively: <a href="/components?c=${esc(part.id)}&amp;p=android">Jetpack Compose</a> and <a href="/components?c=${esc(part.id)}&amp;p=ios">SwiftUI</a>.</p>` : ''}</section>
++<nav class="kt-pn" aria-label="More parts"><a href="/kit/${esc(prev.id)}" rel="prev"><small>Before</small>${esc(prev.name)}</a><a href="/kit" class="kt-pn-all">All ${PARTS.length}</a><a href="/kit/${esc(next.id)}" rel="next"><small>Next</small>${esc(next.name)}</a></nav>
++</div>`;
++ return layout({
++ title: `${part.name} · The OpenVibe Kit`,
++ description: `${part.line} kit.${part.fn}() in openvibe-shared, ${w.bytes ? `${weight.kb(w.brotli)} of CSS` : 'no CSS of its own'}.`,
++ path: `/kit/${part.id}`, active: '/kit', body: `<div class="ds-wrap">${body}</div>`,
++ styles: ['css/kit-pages.css'], sharedStyles: ['kit-data.css'], shared: ['kit-data-web.js'],
++ });
++}
++
++module.exports = { kitIndex, kitPart, pretty, still };
ddiff --git a/test/kit-pages.test.js b/test/kit-pages.test.js
nnew file mode 100644
iindex 0000000..18de820
---- /dev/null
++++ b/test/kit-pages.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * openvibe.design/kit (plan L54 K3): every builder of openvibe-shared/kit has a part (or is a listed helper), every
++ * sample draws, the code a page shows is exactly the code that drew it, a part's weight counts only its own rules,
++ * and the pages: the index by group with still thumbnails, a part's page with its snippets, an unknown part is 404.
++ */
++const assert = require('assert');
++const kit = require('openvibe-shared/kit');
++const { PARTS, GROUPS, HELPERS, render } = require('../server/kit/parts');
++const weight = require('../server/kit/weight');
++const { pretty, still } = require('../server/views/kit');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ await check('every builder is a part or a helper, and every sample draws', () => {
++ const named = new Set([...PARTS.map((p) => p.fn), ...HELPERS.map(([c]) => c.replace(/^kit\.|\(.*$/g, '')), 'sidebarButton', 'withSidebar', 'sheetButton']);
++ const missing = Object.keys(kit).filter((k) => typeof kit[k] === 'function' && !named.has(k));
++ assert.deepStrictEqual(missing, [], 'a new builder in openvibe-shared needs a part on /kit');
++ PARTS.forEach((p) => { assert.ok(render(p).length > 20, `${p.id} draws`); assert.ok(!p.error, `${p.id}: ${p.error}`); });
++ assert.ok(PARTS.every((p) => GROUPS.some(([g]) => g === p.group)), 'every part is in a group');
++ assert.strictEqual(new Set(PARTS.map((p) => p.id)).size, PARTS.length, 'ids are unique');
++ });
++
++ await check('the code a page shows, run as shown, returns the HTML it drew', () => {
++ PARTS.forEach((p) => {
++ const shown = pretty(p.code);
++ assert.strictEqual(String(new Function('kit', `return (${shown});`)(kit)), render(p), `${p.id}: the shown code is the drawn code`);
++ });
++ });
++
++ await check('a part weighs only its own rules', () => {
++ const css = weight.rulesOf('.ovk-btn{a:1}.ovk-tool .ovk-btn{b:2}@media (x){.ovk-btn--quiet,.ovk-zz{c:3}}@keyframes k{from{x:1}}/* .ovk-btn{} */');
++ assert.deepStrictEqual(css.map((r) => r.selector), ['.ovk-btn', '.ovk-tool .ovk-btn', '.ovk-btn--quiet,.ovk-zz']);
++ const btn = weight.ofPart(render(PARTS.find((p) => p.id === 'button')));
++ const tool = weight.ofPart(render(PARTS.find((p) => p.id === 'tool')));
++ assert.ok(btn.rules > 0 && btn.brotli > 0 && btn.files.includes('kit.css'));
++ assert.ok(tool.rules > btn.rules, 'the tool frame reaches more than a button');
++ assert.strictEqual(weight.ofPart(render(PARTS.find((p) => p.id === 'num'))).bytes, 0, 'a number is text');
++ assert.ok(weight.files().some((f) => f.file === 'kit.css' && f.brotli > 1000 && f.brotli < f.bytes));
++ });
++
++ await check('a thumbnail is still: no ids, labels or open tabs of its own', () => {
++ const html = still('<div id="a"><label for="a">x</label><a aria-current="page" href="/">t</a><main>m</main></div>');
++ assert.ok(!/\sid=|\sfor=|\saria-current=|<main/.test(html), html);
++ });
++
++ const t = await boot();
++ try {
++ await check('the index and a part\'s page', async () => {
++ const index = await t.get('/kit');
++ assert.strictEqual(index.status, 200);
++ assert.strictEqual((index.text.match(/<li class="kt-tile/g) || []).length, PARTS.length, 'every part, drawn small');
++ assert.strictEqual((index.text.match(/<a class="kt-tile-a" href="\/kit\/[a-z-]+">/g) || []).length, PARTS.length);
++ const thumbs = index.text.match(/<div class="kt-thumb" aria-hidden="true" inert>[\s\S]*?<div class="kt-tile-t">/g) || [];
++ assert.strictEqual(thumbs.length, PARTS.length);
++ assert.ok(thumbs.every((th) => !/\sid="|\saria-current="/.test(th)), 'thumbnails carry no ids and no open tab');
++ assert.ok(index.text.includes('href="https://openvibe.network/shared/kit.css"') || index.text.includes('https://openvibe.network/shared/kit.css'), 'the CDN');
++ assert.match(index.text, /<nav class="kt-gnav" aria-label="Groups">/);
++ assert.ok(index.text.includes('/shared/kit-data.css'), 'the data parts are drawn with their stylesheet');
++ const part = await t.get('/kit/button');
++ assert.strictEqual(part.status, 200);
++ assert.match(part.text, /<h1>Button<\/h1>/);
++ assert.match(part.text, /kit\.button\(\{ label: &#39;Go live&#39;/, 'the server line');
++ assert.match(part.text, /OpenVibeKit\.button\(/, 'the page line');
++ assert.match(part.text, /of CSS · \d+ rules/);
++ assert.match(part.text, /href="\/components\?c=button&amp;p=android">Jetpack Compose/, 'native where it is');
++ assert.match(part.text, /rel="next"><small>Next<\/small>Icon button/);
++ assert.strictEqual((await t.get('/kit/nope')).status, 404);
++ const page = await t.get('/kit/page');
++ assert.strictEqual((page.text.match(/<main\b/g) || []).length, 1, 'the page sample is not a second <main>');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });