openvibers/openvibe.design Commits
openvibe.design/kit: the OpenVibe Kit, part by part (plan L54 K3) (#88)
Changes
nnew file mode 100644@@@ -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; } }@@@ -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.' },@@@ -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) ──nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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>nnew file mode 100644@@@ -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)}&p=android">Jetpack Compose</a> and <a href="/components?c=${esc(part.id)}&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 };nnew file mode 100644@@@ -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: 'Go live'/, '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&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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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; } }
@@ -31,6 +31,7 @@ function publicPages(themes = []) {
@@ -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: '/', 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: '/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: '/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: '/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: '/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.' },
{ 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.' },
@@ -23,6 +23,7 @@ const views = require('../views');
@@ -23,6 +23,7 @@ const views = require('../views');
const tokensView = require('../views/tokens');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
const lookView = require('../views/look');
const componentsView = require('../views/components');
const componentsView = require('../views/components');
+const kitView = require('../views/kit');
const themeTokens = require('../tokens');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
const patternsView = require('../views/patterns');
const patternsView = require('../views/patterns');
@@ -211,6 +212,12 @@ function createPageRoutes(ctx) {
@@ -211,6 +212,12 @@ function createPageRoutes(ctx) {
});
});
// ── Components: the design system's components for web, Android and iOS (views/components.js) ──
// ── 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));
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) ──
// ── Tokens: any public theme as code (views/tokens.js) ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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 };
@@ -33,6 +33,7 @@ const NAV = [
@@ -33,6 +33,7 @@ const NAV = [
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', 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: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
@@ -46,7 +47,7 @@ const baseUrl = () => BASE;
@@ -46,7 +47,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
/** 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 || '/';
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
const navbar = {
@@ -81,6 +82,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -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('room.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
<link rel="stylesheet" href="${esc(asset('css/design.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')}
${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -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)}&p=android">Jetpack Compose</a> and <a href="/components?c=${esc(part.id)}&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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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: 'Go live'/, '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&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); });