openvibers/openvibe.shared Commits

4.42.0: OpenVibe components — the kit's components as a native-ready contract, kit documents, Compose and SwiftUI reference views (#182)

OP OpenVibers committed 14 days ago · c82859bee2 · 11 files, +790 −28

Eleven components described by anatomy (with the tokens each part wears), typed props, variants, states, a11y and layout, each with a Jetpack Compose and a SwiftUI reference view on the same tokens. Kit documents (a screen as JSON) validate strictly and render through kit.js exactly as direct calls. tokens: the native targets put the scales in one theme-independent OvScale.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex da0dfeb..602db5d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,11 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.42.0 — 2026-10-29
++
++- **OpenVibe components (`openvibe-shared/components`, also `/shared/components.js` as `window.OpenVibeComponents`).** The kit's components as a contract that is not HTML. Eleven components (button, card, rail, row, list, section, fold, tabs, numbers, notice, empty state), each described by its anatomy and the tokens every part wears, its typed props, variants, states, accessibility and phone/tablet/computer behaviour, with a reference view for Jetpack Compose and for SwiftUI on the same tokens. **Kit documents**: a screen as a JSON tree of components, validated strictly (types, enums, required props, children, depth, size, https or same-site links) and rendered through kit.js exactly as the direct builder calls would. The server-driven UI shape a native app can render with its own views. docs/components.md.
++- **tokens:** the Compose, SwiftUI and Flutter files put the kit's scales (radius, space, size, type, motion) in one theme-independent `OvScale`; the theme objects carry the colours.
++
## 4.41.0 — 2026-10-29
- **Try a theme on any OpenVibe page: `?ov-try=<theme>`** (theme-loader.js). It paints a catalog theme on the site without saving it: built-in themes at once, gallery themes from Network's catalog. The try is kept for the tab (sessionStorage) as the person moves around, and every repaint wears the tried theme (the account's sync, another tab, the device's light/dark). A bar at the bottom, above the app bar and 44 px to the touch, says "Trying <name>" with **Use it** (saves to the account, as the studio does) and **Stop** (back to the account's theme; `?ov-try=off` does the same). Only a catalog name is read from the URL, never colours, and nothing is saved until Use it. `OpenVibeThemeLoader.try.start(id)` / `.stop()` / `.current()` do the same from a page. OpenVibe.Design links to it ("Try it on Live"). test/theme-try.test.js.
ddiff --git a/README.md b/README.md
iindex c03ba1a..f5006d5 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.41.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.42.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -656,6 +656,10 @@ piece, the sizes, the colours, and how to use it in an OpenVibe site or anyone's
Tailwind, Jetpack Compose, SwiftUI, Flutter and Figma variables, so native apps wear the same theme as the web, without a
web view ([docs/tokens.md](docs/tokens.md)).
++**OpenVibe components** (`openvibe-shared/components`, 4.42.0) is the kit's components as a contract (anatomy, tokens, states,
++accessibility) with Compose and SwiftUI reference views, plus kit documents: a screen as validated JSON that the web
++renders through kit.js and a native app with its own views ([docs/components.md](docs/components.md)).
++
```js
const kit = require('openvibe-shared/kit');
kit.head({ title: 'Settings', back: { href: '/', label: 'Home' }, tabs: kit.tabs({ base: '/settings', items, active }) })
ddiff --git a/STATUS.json b/STATUS.json
iindex c00c4d6..2dc754f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.41.0",
++ "version": "4.42.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/components.js b/components.js
nnew file mode 100644
iindex 0000000..8703713
---- /dev/null
++++ b/components.js
@@@ -0,0 +1,605 @@
++/**
++ * openvibe-shared/components.js — the OpenVibe kit's components as a contract that is not HTML, so the web, Android and
++ * iOS can each draw them natively (the owner, 2026-10-29: "translate it to official and optimized Android and iOS apps …
++ * that aren't just web browser reskin kiosks").
++ *
++ * Every component is described by data: what it is for, its parts (anatomy) and the design tokens each part wears
++ * (openvibe-shared/tokens' names), its props, variants, states, accessibility and how it behaves on a phone, a tablet and
++ * a computer. The web draws it with kit.js today; `native` holds a reference view for Jetpack Compose and for SwiftUI
++ * that uses the same tokens (Material 3 roles from the theme's colour scheme, OvScale for the scales).
++ *
++ * A screen can then be sent as a **kit document**, a JSON tree of components that any client renders with its own
++ * views (the server-driven UI pattern of Airbnb's Ghost Platform and Yandex's DivKit):
++ *
++ * { type: 'section', props: { title: 'Right now' }, children: [
++ * { type: 'rail', props: { id: 'now', title: 'Live', more: { href: '/live', label: 'See all' } }, children: [
++ * { type: 'card', props: { href: '/c/1', title: 'Night walk', type: 'stream', live: true, who: 'goosely' } } ] } ] }
++ *
++ * const components = require('openvibe-shared/components'); // in a page: /shared/components.js → OpenVibeComponents
++ * components.SPECS the contract: [{ id, name, summary, role, anatomy, props, variants, … }]
++ * components.get(id) one spec
++ * components.validate(doc) → { ok, errors: ['card.props.title: required', …] }
++ * components.render(doc, kit) → HTML through kit.js (refuses an invalid document); kit defaults to window.OpenVibeKit
++ *
++ * validate() is strict: known types and props only, typed values, enums, required props, which children a component
++ * takes, at most 8 levels and 400 nodes, strings at most 2,000 characters. render() goes through kit.js, which escapes
++ * every text and keeps only http(s) or same-site links, so a document from anywhere renders safely.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeComponents = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const VERSION = 1;
++ const LIMITS = Object.freeze({ depth: 8, nodes: 400, string: 2000, items: 60 });
++
++ // Prop types: 'string', 'text' (long string), 'url', 'boolean', 'number', 'icon', ['a', 'b'] (one of), 'link'
++ // ({ href, label }), 'count' ([n, 'word'] or n), 'stats' ([[value, label, href?]…]), 'tabs' ([{ key, label, count }]).
++ const STATES = { press: ['default', 'hover', 'pressed', 'focused', 'disabled'], read: ['default', 'empty', 'loading'] };
++
++ const SPECS = [
++ {
++ id: 'button', name: 'Button', web: 'kit.button',
++ summary: 'One action: a link or a form button. Primary for the one thing to do, quiet beside it, danger for what cannot be undone.',
++ role: 'button (link when it has href)',
++ anatomy: [
++ { part: 'container', tokens: { background: 'color.accent.strong', radius: 'radius.pill', minHeight: 'size.tap', paddingX: 'space.lg' } },
++ { part: 'label', tokens: { color: 'color.accent.onStrong', size: 'type.size.body', weight: 'type.weight.bold' } },
++ { part: 'icon', optional: true, tokens: { size: 'size.icon', color: 'color.accent.onStrong' } },
++ ],
++ props: { label: { type: 'string', required: true }, href: { type: 'url' }, icon: { type: 'icon' }, kind: { type: ['primary', 'quiet', 'danger'], default: 'primary' }, small: { type: 'boolean' } },
++ variants: { kind: { primary: 'container color.accent.strong, label color.accent.onStrong', quiet: 'container transparent with a color.line.strong outline, label color.text.primary', danger: 'container color.status.bad.base, label color.accent.onStrong' } },
++ states: STATES.press,
++ a11y: { name: 'its label', keys: 'Enter (and Space as a button)', minTarget: '44 × 44 on touch' },
++ layout: { phone: 'full width in a form\'s last row; two side by side in a hero', computer: 'its own width' },
++ native: {
++ compose: `@Composable
++fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary, icon: ImageVector? = null) {
++ val c = MaterialTheme.colorScheme
++ val colors = when (kind) {
++ OvKind.Primary -> ButtonDefaults.buttonColors(containerColor = c.primaryContainer, contentColor = c.onPrimaryContainer)
++ OvKind.Danger -> ButtonDefaults.buttonColors(containerColor = c.error, contentColor = c.onError)
++ OvKind.Quiet -> ButtonDefaults.outlinedButtonColors(contentColor = c.onSurface)
++ }
++ val content: @Composable RowScope.() -> Unit = {
++ if (icon != null) { Icon(icon, contentDescription = null, Modifier.size(OvScale.SizeIcon)); Spacer(Modifier.width(OvScale.SpaceSm)) }
++ Text(label, fontWeight = FontWeight.Bold)
++ }
++ val m = Modifier.heightIn(min = OvScale.SizeTap)
++ if (kind == OvKind.Quiet) OutlinedButton(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, border = BorderStroke(1.dp, c.outline), content = content)
++ else Button(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, content = content)
++}`,
++ swiftui: `struct OvButton: View {
++ let label: String
++ var kind: OvKind = .primary
++ var icon: String? = nil
++ let action: () -> Void
++ var body: some View {
++ Button(action: action) {
++ Label { Text(label).fontWeight(.bold) } icon: { if let icon { Image(systemName: icon) } }
++ .frame(minHeight: OvScale.sizeTap)
++ .padding(.horizontal, OvScale.spaceLg)
++ }
++ .buttonStyle(.borderedProminent)
++ .buttonBorderShape(.capsule)
++ .tint(kind == .danger ? OvTokens.colorStatusBadBase : kind == .quiet ? .clear : OvTokens.colorAccentStrong)
++ .foregroundStyle(kind == .quiet ? OvTokens.colorTextPrimary : OvTokens.colorAccentOnStrong)
++ }
++}`,
++ },
++ },
++ {
++ id: 'card', name: 'Card', web: 'kit.card',
++ summary: 'One thing, the same anatomy everywhere: a stream, a clip, a listing, a game, a paste. The whole card is one link.',
++ role: 'article with a heading link',
++ anatomy: [
++ { part: 'media', tokens: { background: 'color.bg.hover', radius: 'radius.md' }, note: 'in the type\'s shape (16:9 streams and clips, 1:1 listings and memes, 4:3 games and reports); a picture, a text preview or the type\'s icon' },
++ { part: 'badge', optional: true, tokens: { background: 'rgba(0,0,0,.74)', color: '#fff' }, note: 'top left, one word; LIVE is color.status.live.base' },
++ { part: 'corner', optional: true, note: 'bottom right: a length or a price' },
++ { part: 'title', tokens: { color: 'color.text.primary', size: 'type.size.body', weight: 'type.weight.bold' }, note: 'two lines at most' },
++ { part: 'sub', optional: true, tokens: { color: 'color.text.secondary', size: 'type.size.meta' }, note: 'one plain line' },
++ { part: 'meta', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' }, note: 'who · one count · when; a count of 0 is left out' },
++ { part: 'more', optional: true, note: 'the ⋯ menu: Save, Remind, Share' },
++ ],
++ props: { href: { type: 'url', required: true }, title: { type: 'string', required: true }, type: { type: 'string' }, ratio: { type: 'string' }, image: { type: 'url' }, alt: { type: 'string' }, badge: { type: 'string' }, live: { type: 'boolean' }, corner: { type: 'string' }, sub: { type: 'string' }, preview: { type: 'text' }, who: { type: 'string' }, avatar: { type: 'url' }, count: { type: 'count' }, when: { type: 'string' }, level: { type: 'number' } },
++ variants: { type: { stream: '16:9, a LIVE badge when live', clip: '16:9 with its length', listing: '1:1 with its price', game: '4:3', post: '16:9, a text preview when it has no picture' } },
++ states: ['default', 'hover (the picture zooms, the peek shows)', 'focused', 'live'],
++ a11y: { name: 'its title (the heading\'s link)', keys: 'Tab to the card, Enter opens it; the menu is its own stop', minTarget: 'the whole card' },
++ layout: { phone: 'about 1.2 to a rail, swiped', computer: 'a fixed width in a rail, or a grid' },
++ native: {
++ compose: `@Composable
++fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Float = 16f / 9f, badge: String? = null, sub: String? = null, meta: String? = null) {
++ val c = MaterialTheme.colorScheme
++ Column(Modifier.clickable(onClickLabel = title, onClick = onOpen).semantics(mergeDescendants = true) { heading() }) {
++ Box(Modifier.fillMaxWidth().aspectRatio(ratio).clip(RoundedCornerShape(OvScale.RadiusMd)).background(c.surfaceVariant)) {
++ if (image != null) AsyncImage(image, contentDescription = null, Modifier.matchParentSize(), contentScale = ContentScale.Crop)
++ if (badge != null) Text(badge, Modifier.padding(OvScale.SpaceSm).background(Color(0xBD000000), RoundedCornerShape(6.dp)).padding(horizontal = 8.dp), color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp)
++ }
++ Spacer(Modifier.height(OvScale.SpaceMd))
++ Text(title, color = c.onSurface, fontWeight = FontWeight.Bold, fontSize = OvScale.TypeSizeBody, maxLines = 2, overflow = TextOverflow.Ellipsis)
++ if (sub != null) Text(sub, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)
++ if (meta != null) Text(meta, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1)
++ }
++}`,
++ swiftui: `struct OvCard: View {
++ let title: String
++ var image: URL? = nil
++ var ratio: CGFloat = 16 / 9
++ var badge: String? = nil
++ var sub: String? = nil
++ var meta: String? = nil
++ var body: some View {
++ VStack(alignment: .leading, spacing: 2) {
++ ZStack(alignment: .topLeading) {
++ RoundedRectangle(cornerRadius: OvScale.radiusMd).fill(OvTokens.colorBgHover)
++ if let image { AsyncImage(url: image) { $0.resizable().scaledToFill() } placeholder: { Color.clear } }
++ if let badge { Text(badge).font(.caption2.weight(.heavy)).foregroundStyle(.white).padding(.horizontal, 8).background(.black.opacity(0.74), in: .rect(cornerRadius: 6)).padding(OvScale.spaceSm) }
++ }
++ .aspectRatio(ratio, contentMode: .fit).clipShape(.rect(cornerRadius: OvScale.radiusMd))
++ .padding(.bottom, OvScale.spaceSm)
++ Text(title).font(.system(size: OvScale.typeSizeBody, weight: .bold)).foregroundStyle(OvTokens.colorTextPrimary).lineLimit(2)
++ if let sub { Text(sub).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextSecondary).lineLimit(1) }
++ if let meta { Text(meta).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted).lineLimit(1) }
++ }
++ .accessibilityElement(children: .combine).accessibilityAddTraits(.isLink)
++ }
++}`,
++ },
++ },
++ {
++ id: 'rail', name: 'Rail', web: 'kit.rail',
++ summary: 'A row of cards that scrolls sideways and snaps, with its heading and one See all link. From 760 px it can wrap into a grid.',
++ role: 'region named by its heading; a list of cards',
++ children: ['card'],
++ anatomy: [
++ { part: 'head', tokens: { size: 'type.size.lead', weight: 'type.weight.heavy' }, note: 'the title and See all (44 px to the touch)' },
++ { part: 'track', tokens: { gap: 'space.md' }, note: 'snaps to each card; bleeds to the screen\'s edge on a phone' },
++ { part: 'arrows', optional: true, note: '‹ › on computers only, each hidden at its end' },
++ ],
++ props: { id: { type: 'string' }, title: { type: 'string' }, more: { type: 'link' }, size: { type: ['s', 'm', 'l'], default: 'm' }, wrap: { type: 'boolean' }, label: { type: 'string' } },
++ variants: { size: { s: 'square things (memes, deals), 190 px', m: '16:9 pictures, 280 px', l: 'wide features, 400 px' } },
++ states: STATES.read,
++ a11y: { name: 'its title', keys: 'Tab moves card to card; the arrows are buttons', minTarget: 'See all 44 px' },
++ layout: { phone: 'about 1.2 cards showing, swiped', tablet: 'as many as fit', computer: 'arrows, or a grid with wrap' },
++ native: {
++ compose: `@Composable
++fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.() -> Unit) {
++ Column(Modifier.semantics { heading() }) {
++ Row(Modifier.fillMaxWidth().padding(horizontal = OvScale.SpaceLg), verticalAlignment = Alignment.CenterVertically) {
++ Text(title, Modifier.weight(1f), fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeLead)
++ if (seeAll != null) TextButton(seeAll, Modifier.heightIn(min = OvScale.SizeTap)) { Text("See all") }
++ }
++ val state = rememberLazyListState()
++ LazyRow(state = state, flingBehavior = rememberSnapFlingBehavior(state), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg),
++ horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceMd), content = content)
++ }
++}`,
++ swiftui: `struct OvRail<Content: View>: View {
++ let title: String
++ var seeAll: (() -> Void)? = nil
++ @ViewBuilder let content: () -> Content
++ var body: some View {
++ VStack(alignment: .leading, spacing: OvScale.spaceMd) {
++ HStack {
++ Text(title).font(.system(size: OvScale.typeSizeLead, weight: .heavy)).accessibilityAddTraits(.isHeader)
++ Spacer()
++ if let seeAll { Button("See all", action: seeAll).frame(minHeight: OvScale.sizeTap) }
++ }.padding(.horizontal, OvScale.spaceLg)
++ ScrollView(.horizontal, showsIndicators: false) {
++ LazyHStack(spacing: OvScale.spaceMd) { content() }.scrollTargetLayout().padding(.horizontal, OvScale.spaceLg)
++ }.scrollTargetBehavior(.viewAligned)
++ }
++ }
++}`,
++ },
++ },
++ {
++ id: 'row', name: 'Row', web: 'kit.row',
++ summary: 'One item in a list: its mark, the title with a badge and the time on one line, one muted line under it, a trailing control.',
++ role: 'listitem (a link when it has href)',
++ anatomy: [
++ { part: 'mark', optional: true, tokens: { size: 'size.avatar' }, note: 'a picture, or up to two letters on a colour from the name' },
++ { part: 'title', tokens: { color: 'color.text.primary', weight: 'type.weight.bold' } },
++ { part: 'meta', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' }, note: 'the time, on the title\'s line' },
++ { part: 'sub', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' }, note: 'one line, cut with …' },
++ { part: 'trail', optional: true, note: 'a count, a switch or a button' },
++ ],
++ props: { href: { type: 'url' }, rel: { type: 'string' }, title: { type: 'string', required: true }, meta: { type: 'string' }, sub: { type: 'string' }, mark: { type: 'string' }, unread: { type: 'number' } },
++ variants: { unread: { '0': 'plain', 'n': 'bold title and a dot with the count' } },
++ states: STATES.press,
++ a11y: { name: 'its title and sub-line', keys: 'Enter opens it', minTarget: 'the row, at least 56 px tall' },
++ layout: { phone: 'full width, divided by lines', computer: 'the same, in a panel' },
++ native: {
++ compose: `@Composable
++fun OvRow(title: String, onOpen: (() -> Unit)? = null, sub: String? = null, meta: String? = null, mark: (@Composable () -> Unit)? = null, trail: (@Composable () -> Unit)? = null) {
++ ListItem(
++ headlineContent = { Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) },
++ supportingContent = sub?.let { { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = OvScale.TypeSizeMeta) } },
++ leadingContent = mark, trailingContent = trail ?: meta?.let { { Text(it, fontSize = OvScale.TypeSizeMeta) } },
++ modifier = (if (onOpen != null) Modifier.clickable(onClick = onOpen) else Modifier).heightIn(min = 56.dp),
++ )
++}`,
++ swiftui: `struct OvRow: View {
++ let title: String
++ var sub: String? = nil
++ var meta: String? = nil
++ var body: some View {
++ HStack(alignment: .firstTextBaseline) {
++ VStack(alignment: .leading, spacing: 2) {
++ Text(title).fontWeight(.bold).lineLimit(1)
++ if let sub { Text(sub).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted).lineLimit(1) }
++ }
++ Spacer()
++ if let meta { Text(meta).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted) }
++ }
++ .frame(minHeight: 56)
++ .accessibilityElement(children: .combine)
++ }
++}`,
++ },
++ },
++ {
++ id: 'list', name: 'List', web: 'kit.list',
++ summary: 'Rows in one rounded panel, with a small heading and one link to the whole list; an empty list says so in a line.',
++ role: 'list',
++ children: ['row'],
++ anatomy: [
++ { part: 'head', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' }, note: 'uppercase, with its link' },
++ { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } },
++ ],
++ props: { title: { type: 'string' }, more: { type: 'link' }, empty: { type: 'string' }, flush: { type: 'boolean' } },
++ states: STATES.read,
++ a11y: { name: 'its heading', keys: 'Tab row to row', minTarget: 'rows 56 px' },
++ layout: { phone: 'full width', computer: 'in its column' },
++ native: {
++ compose: `@Composable
++fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {
++ Column {
++ if (title != null) Text(title.uppercase(), Modifier.padding(OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), tonalElevation = 1.dp) { Column(content = content) }
++ }
++}`,
++ swiftui: `struct OvList<Content: View>: View {
++ var title: String? = nil
++ @ViewBuilder let content: () -> Content
++ var body: some View {
++ Section { content() } header: { if let title { Text(title).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)) } }
++ }
++}`,
++ },
++ },
++ {
++ id: 'section', name: 'Section', web: 'kit.section',
++ summary: 'A card with a small heading for one part of a page or a group of settings; a note is one muted line under the title.',
++ role: 'region named by its heading',
++ children: '*',
++ anatomy: [
++ { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } },
++ { part: 'title', tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' } },
++ { part: 'note', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' } },
++ ],
++ props: { id: { type: 'string' }, title: { type: 'string' }, count: { type: 'number' }, note: { type: 'string' } },
++ states: ['default'],
++ a11y: { name: 'its title', keys: 'none of its own', minTarget: '' },
++ layout: { phone: 'full width', computer: 'in its column' },
++ native: {
++ compose: `@Composable
++fun OvSection(title: String, note: String? = null, content: @Composable ColumnScope.() -> Unit) {
++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
++ Column(Modifier.padding(vertical = OvScale.SpaceMd)) {
++ Text(title.uppercase(), Modifier.padding(horizontal = OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
++ if (note != null) Text(note, Modifier.padding(horizontal = OvScale.SpaceMd), fontSize = OvScale.TypeSizeMeta, color = MaterialTheme.colorScheme.onSurfaceVariant)
++ content()
++ }
++ }
++}`,
++ swiftui: `struct OvSection<Content: View>: View {
++ let title: String
++ var note: String? = nil
++ @ViewBuilder let content: () -> Content
++ var body: some View {
++ GroupBox {
++ VStack(alignment: .leading) { content() }
++ } label: {
++ VStack(alignment: .leading) { Text(title.uppercased()).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)); if let note { Text(note).font(.system(size: OvScale.typeSizeMeta)) } }
++ }
++ }
++}`,
++ },
++ },
++ {
++ id: 'fold', name: 'Fold', web: 'kit.fold',
++ summary: 'A card that opens and closes, for what is read once: setup, extras, details, the danger zone. Closed unless told.',
++ role: 'disclosure (details/summary)',
++ children: '*',
++ anatomy: [
++ { part: 'summary', tokens: { minHeight: 'size.tap', weight: 'type.weight.bold' }, note: 'the title, a muted word after it, a chevron that turns' },
++ { part: 'body', tokens: { padding: 'space.md' } },
++ ],
++ props: { id: { type: 'string' }, title: { type: 'string', required: true }, meta: { type: 'string' }, open: { type: 'boolean' }, tone: { type: ['', 'danger'] } },
++ states: ['closed', 'open', 'focused'],
++ a11y: { name: 'its title', keys: 'Enter or Space opens and closes', minTarget: '44 px' },
++ layout: { phone: 'full width', computer: 'in its column' },
++ native: {
++ compose: `@Composable
++fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false, content: @Composable () -> Unit) {
++ var open by rememberSaveable { mutableStateOf(initiallyOpen) }
++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd)) {
++ Column {
++ Row(Modifier.fillMaxWidth().heightIn(min = OvScale.SizeTap).clickable(onClickLabel = if (open) "Close" else "Open") { open = !open }.padding(horizontal = OvScale.SpaceMd),
++ verticalAlignment = Alignment.CenterVertically) {
++ Text(title, Modifier.weight(1f), fontWeight = FontWeight.Bold)
++ if (meta != null) Text(meta, color = MaterialTheme.colorScheme.onSurfaceVariant)
++ Icon(Icons.Default.ExpandMore, null, Modifier.rotate(if (open) 180f else 0f))
++ }
++ AnimatedVisibility(open) { Box(Modifier.padding(OvScale.SpaceMd)) { content() } }
++ }
++ }
++}`,
++ swiftui: `struct OvFold<Content: View>: View {
++ let title: String
++ var meta: String? = nil
++ @State var open = false
++ @ViewBuilder let content: () -> Content
++ var body: some View {
++ DisclosureGroup(isExpanded: $open) { content().padding(.top, OvScale.spaceSm) } label: {
++ HStack { Text(title).fontWeight(.bold); if let meta { Text(meta).foregroundStyle(OvTokens.colorTextMuted) } }.frame(minHeight: OvScale.sizeTap)
++ }
++ }
++}`,
++ },
++ },
++ {
++ id: 'tabs', name: 'Tabs', web: 'kit.tabs',
++ summary: 'A long page as a few short ones: each tab is a link (?tab=key), the server builds only the open one, so nothing needs JavaScript.',
++ role: 'navigation; the open tab is aria-current',
++ anatomy: [
++ { part: 'pill', tokens: { minHeight: 'size.tap', radius: 'radius.pill', color: 'color.text.muted' }, note: 'the open one on color.bg.hover with a line, color.text.primary' },
++ { part: 'count', optional: true, tokens: { size: 'type.size.caption', weight: 'type.weight.heavy' } },
++ ],
++ props: { base: { type: 'url' }, items: { type: 'tabs', required: true }, active: { type: 'string' }, param: { type: 'string' }, label: { type: 'string' } },
++ states: ['default', 'current', 'focused'],
++ a11y: { name: 'its label (Sections)', keys: 'Tab between links; Enter opens', minTarget: '44 px on touch' },
++ layout: { phone: 'one strip that scrolls sideways', computer: 'in a row' },
++ native: {
++ compose: `@Composable
++fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {
++ ScrollableTabRow(selectedTabIndex = selected, edgePadding = OvScale.SpaceLg) {
++ tabs.forEachIndexed { i, t -> Tab(selected = i == selected, onClick = { onSelect(i) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), text = { Text(t) }) }
++ }
++}`,
++ swiftui: `struct OvTabs: View {
++ let tabs: [String]
++ @Binding var selected: Int
++ var body: some View {
++ Picker("Sections", selection: $selected) { ForEach(tabs.indices, id: \\.self) { Text(tabs[$0]).tag($0) } }
++ .pickerStyle(.segmented)
++ }
++}`,
++ },
++ },
++ {
++ id: 'stats', name: 'Numbers', web: 'kit.stats',
++ summary: 'A strip of real numbers; zero and unknown values are left out. A number with a link makes its whole tile the link.',
++ role: 'description list (label: value)',
++ anatomy: [
++ { part: 'tile', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } },
++ { part: 'value', tokens: { color: 'color.text.primary', size: 'type.size.title', weight: 'type.weight.heavy' }, note: 'tabular figures' },
++ { part: 'label', tokens: { color: 'color.text.muted', size: 'type.size.caption' } },
++ ],
++ props: { items: { type: 'stats', required: true }, label: { type: 'string' } },
++ states: ['default'],
++ a11y: { name: 'each label with its value', keys: 'a linked tile is one Tab stop', minTarget: 'the tile' },
++ layout: { phone: 'one strip that scrolls sideways', tablet: 'a grid of 130 px tiles', computer: 'the same' },
++ native: {
++ compose: `@Composable
++fun OvStats(items: List<Pair<String, String>>) {
++ LazyRow(horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg)) {
++ items(items) { (value, label) ->
++ Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
++ Column(Modifier.widthIn(min = 104.dp).padding(horizontal = 14.dp, vertical = 8.dp).semantics(mergeDescendants = true) {}) {
++ Text(value, fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeTitle, style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"))
++ Text(label, fontSize = OvScale.TypeSizeCaption, color = MaterialTheme.colorScheme.onSurfaceVariant)
++ }
++ }
++ }
++ }
++}`,
++ swiftui: `struct OvStats: View {
++ let items: [(value: String, label: String)]
++ var body: some View {
++ ScrollView(.horizontal, showsIndicators: false) {
++ HStack(spacing: OvScale.spaceSm) {
++ ForEach(items, id: \\.label) { item in
++ VStack(alignment: .leading) {
++ Text(item.value).font(.system(size: OvScale.typeSizeTitle, weight: .heavy)).monospacedDigit()
++ Text(item.label).font(.system(size: OvScale.typeSizeCaption)).foregroundStyle(OvTokens.colorTextMuted)
++ }
++ .padding(.horizontal, 14).padding(.vertical, 8).frame(minWidth: 104, alignment: .leading)
++ .background(OvTokens.colorBgRaised, in: .rect(cornerRadius: OvScale.radiusMd))
++ .accessibilityElement(children: .combine)
++ }
++ }.padding(.horizontal, OvScale.spaceLg)
++ }
++ }
++}`,
++ },
++ },
++ {
++ id: 'notice', name: 'Notice', web: 'kit.notice',
++ summary: 'A line in the page that tells the person what happened: ok, info, warn, or an error (announced at once).',
++ role: 'status, or alert for errors',
++ anatomy: [
++ { part: 'icon', tokens: { size: 'size.icon' } },
++ { part: 'text', tokens: { color: 'color.text.primary' } },
++ ],
++ props: { kind: { type: ['ok', 'info', 'warn', 'error'], default: 'info' }, text: { type: 'string', required: true } },
++ variants: { kind: { ok: 'color.status.ok.text', info: 'color.status.info.text', warn: 'color.status.warn.text', error: 'color.status.bad.text, role alert' } },
++ states: ['default'],
++ a11y: { name: 'its text', keys: 'none; errors are announced', minTarget: '' },
++ layout: { phone: 'full width', computer: 'in its column' },
++ native: {
++ compose: `@Composable
++fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {
++ val c = MaterialTheme.colorScheme
++ val tint = when (kind) { OvNoticeKind.Error -> c.error; OvNoticeKind.Ok -> c.primary; else -> c.tertiary }
++ Row(Modifier.fillMaxWidth().semantics { liveRegion = if (kind == OvNoticeKind.Error) LiveRegionMode.Assertive else LiveRegionMode.Polite }
++ .background(tint.copy(alpha = .12f), RoundedCornerShape(OvScale.RadiusSm)).padding(OvScale.SpaceMd), horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {
++ Icon(if (kind == OvNoticeKind.Error) Icons.Default.Error else Icons.Default.Info, null, tint = tint)
++ Text(text, color = c.onSurface)
++ }
++}`,
++ swiftui: `struct OvNotice: View {
++ let text: String
++ var isError = false
++ var body: some View {
++ Label(text, systemImage: isError ? "exclamationmark.triangle" : "info.circle")
++ .padding(OvScale.spaceMd).frame(maxWidth: .infinity, alignment: .leading)
++ .background((isError ? OvTokens.colorStatusBadText : OvTokens.colorStatusInfoText).opacity(0.12), in: .rect(cornerRadius: OvScale.radiusSm))
++ .accessibilityAddTraits(isError ? [.updatesFrequently] : [])
++ }
++}`,
++ },
++ },
++ {
++ id: 'empty', name: 'Empty state', web: 'kit.empty',
++ summary: 'What an empty list says: a title, one line, and the one thing to do next.',
++ role: 'status',
++ anatomy: [
++ { part: 'icon', optional: true, tokens: { color: 'color.accent.base' } },
++ { part: 'title', tokens: { weight: 'type.weight.bold' } },
++ { part: 'text', optional: true, tokens: { color: 'color.text.muted' } },
++ { part: 'action', optional: true, note: 'a primary button' },
++ ],
++ props: { title: { type: 'string', required: true }, text: { type: 'string' }, icon: { type: 'icon' }, action: { type: 'link' } },
++ states: ['default'],
++ a11y: { name: 'its title and line', keys: 'the action is one Tab stop', minTarget: '44 px' },
++ layout: { phone: 'centred in the space', computer: 'the same' },
++ native: {
++ compose: `@Composable
++fun OvEmpty(title: String, text: String? = null, action: Pair<String, () -> Unit>? = null) {
++ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceXl), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) {
++ Text(title, fontWeight = FontWeight.Bold)
++ if (text != null) Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
++ if (action != null) OvButton(action.first, action.second)
++ }
++}`,
++ swiftui: `struct OvEmpty: View {
++ let title: String
++ var text: String? = nil
++ var body: some View {
++ ContentUnavailableView { Text(title).fontWeight(.bold) } description: { if let text { Text(text) } }
++ }
++}`,
++ },
++ },
++ ];
++
++ // What the reference views share, once per app: the kinds, and (iOS) which theme's tokens the app wears. The theme
++ // files come from openvibe-shared/tokens (OpenVibe.Design /tokens): Compose reads MaterialTheme's colour scheme
++ // (VibeDarkColorScheme…) and OvScale; SwiftUI reads OvTokens (an alias of the theme's enum) and OvScale.
++ const PREAMBLE = Object.freeze({
++ compose: `// OpenVibe kit for Jetpack Compose: add the theme file from openvibe.design/tokens (Android) and wrap the app in
++// MaterialTheme(colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme else VibeLightColorScheme) { … }
++enum class OvKind { Primary, Quiet, Danger }
++enum class OvNoticeKind { Ok, Info, Warn, Error }`,
++ swiftui: `// OpenVibe kit for SwiftUI: add the theme file from openvibe.design/tokens (iOS) and say which half the app wears.
++typealias OvTokens = VibeDarkTokens // or VibeLightTokens, from @Environment(\\.colorScheme)
++enum OvKind { case primary, quiet, danger }`,
++ });
++
++ const byId = new Map(SPECS.map((c) => [c.id, c]));
++ const get = (id) => byId.get(String(id)) || null;
++
++ // ── Validation ──
++ const isStr = (v) => typeof v === 'string' && v.length <= LIMITS.string;
++ const isUrl = (v) => isStr(v) && /^(https:\/\/[^\s"'<>]+|\/(?!\/)[^\s"'<>]*|#[\w-]*)$/.test(v.trim());
++ function checkType(type, v) {
++ if (Array.isArray(type)) return type.includes(v);
++ switch (type) {
++ case 'string': case 'text': case 'icon': return isStr(v);
++ case 'url': return isUrl(v);
++ case 'boolean': return typeof v === 'boolean';
++ case 'number': return typeof v === 'number' && Number.isFinite(v);
++ case 'link': return !!v && typeof v === 'object' && isUrl(v.href) && (v.label == null || isStr(v.label));
++ case 'count': return (typeof v === 'number' && Number.isFinite(v)) || (Array.isArray(v) && v.length <= 2 && Number.isFinite(Number(v[0])) && (v[1] == null || isStr(v[1])));
++ case 'stats': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => Array.isArray(x) && x.length >= 2 && x.length <= 3 && (isStr(x[0]) || Number.isFinite(x[0])) && isStr(x[1]) && (x[2] == null || isUrl(x[2])));
++ case 'tabs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isStr(x.key) && isStr(x.label) && (x.count == null || Number.isFinite(Number(x.count))) && (x.href == null || isUrl(x.href)));
++ default: return false;
++ }
++ }
++ /** A kit document against the contract → { ok, errors }. */
++ function validate(doc) {
++ const errors = [];
++ let nodes = 0;
++ (function walk(node, at, depth) {
++ if (errors.length > 50) return;
++ nodes++;
++ if (nodes > LIMITS.nodes) { if (nodes === LIMITS.nodes + 1) errors.push(`more than ${LIMITS.nodes} nodes`); return; }
++ if (depth > LIMITS.depth) { errors.push(`${at}: deeper than ${LIMITS.depth}`); return; }
++ if (!node || typeof node !== 'object' || Array.isArray(node)) { errors.push(`${at}: not a component`); return; }
++ const spec = get(node.type);
++ if (!spec) { errors.push(`${at}: unknown type ${JSON.stringify(String(node.type)).slice(0, 40)}`); return; }
++ const here = `${at === '$' ? '' : `${at}.`}${spec.id}`;
++ const props = node.props == null ? {} : node.props;
++ if (typeof props !== 'object' || Array.isArray(props)) { errors.push(`${here}.props: not an object`); return; }
++ for (const k of Object.keys(props)) {
++ const p = spec.props[k];
++ if (!p) errors.push(`${here}.props.${k}: not a prop of ${spec.id}`);
++ else if (props[k] != null && !checkType(p.type, props[k])) errors.push(`${here}.props.${k}: expected ${Array.isArray(p.type) ? p.type.map((x) => JSON.stringify(x)).join(' | ') : p.type}`);
++ }
++ for (const [k, p] of Object.entries(spec.props)) if (p.required && (props[k] == null || props[k] === '')) errors.push(`${here}.props.${k}: required`);
++ const kids = node.children == null ? [] : node.children;
++ if (!Array.isArray(kids)) { errors.push(`${here}.children: not a list`); return; }
++ if (kids.length && !spec.children) errors.push(`${here}: takes no children`);
++ if (kids.length > LIMITS.items) errors.push(`${here}.children: more than ${LIMITS.items}`);
++ kids.slice(0, LIMITS.items).forEach((c, i) => {
++ if (spec.children && spec.children !== '*' && c && !spec.children.includes(c.type)) errors.push(`${here}.children[${i}]: ${spec.id} takes ${spec.children.join(', ')}, not ${String(c.type).slice(0, 30)}`);
++ walk(c, `${here}[${i}]`, depth + 1);
++ });
++ })(doc, '$', 1);
++ return { ok: errors.length === 0, errors };
++ }
++
++ // ── Rendering for the web, through kit.js ──
++ /**
++ * A valid kit document as HTML (the kit's own builders: escaped, links checked). An invalid one throws. kit: kit.js —
++ * in a page, window.OpenVibeKit (the default); on a server, require('openvibe-shared/kit').
++ */
++ function render(doc, kitLib = root && root.OpenVibeKit) {
++ if (!kitLib || typeof kitLib.card !== 'function') throw new Error('components.render(doc, kit): pass kit.js (openvibe-shared/kit on a server, window.OpenVibeKit in a page)');
++ const v = validate(doc);
++ if (!v.ok) throw Object.assign(new Error(`invalid kit document: ${v.errors.slice(0, 3).join('; ')}`), { errors: v.errors });
++ const kit = kitLib;
++ const draw = (n) => {
++ const p = n.props || {};
++ const kids = (n.children || []).map(draw);
++ switch (n.type) {
++ case 'button': return kit.button({ href: p.href == null ? null : p.href, type: 'button', label: p.label, icon: p.icon || '', kind: p.kind || 'primary', small: !!p.small });
++ case 'card': return kit.card({ ...p, when: p.when || null });
++ case 'rail': return kit.rail({ ...p, items: kids });
++ case 'row': return kit.row({ ...p, mark: p.mark ? kit.mark({ name: p.mark }) : '' });
++ case 'list': return kit.list(kids, { title: p.title || '', more: p.more || null, flush: !!p.flush, empty: p.empty || '' });
++ case 'section': return kit.section({ id: p.id || null, title: p.title || '', count: p.count == null ? null : p.count, note: p.note || '', body: kids.join('') });
++ case 'fold': return kit.fold({ id: p.id || null, title: p.title, meta: p.meta || '', open: !!p.open, tone: p.tone || '', body: kids.join('') });
++ case 'tabs': return kit.tabs({ base: p.base || '', items: p.items, active: p.active || '', param: p.param || 'tab', label: p.label || 'Sections' });
++ case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });
++ case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });
++ case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });
++ default: return '';
++ }
++ };
++ return draw(doc);
++ }
++
++ /** Every token path a spec names (for tests and docs). */
++ function tokensOf(spec) {
++ const out = new Set();
++ for (const part of spec.anatomy || []) for (const v of Object.values(part.tokens || {})) if (/^[a-z]+(\.[a-zA-Z]+)+$/.test(v)) out.add(v);
++ return [...out];
++ }
++
++ return { VERSION, LIMITS, SPECS, PREAMBLE, get, validate, render, tokensOf };
++});
ddiff --git a/docs/components.md b/docs/components.md
nnew file mode 100644
iindex 0000000..4cfeb65
---- /dev/null
++++ b/docs/components.md
@@@ -0,0 +1,63 @@
++# OpenVibe components (`openvibe-shared/components`, 4.42.0)
++
++The kit's components as a **contract that is not HTML**. The web draws them with kit.js today; an Android or iOS app
++draws the same components with its own views (Jetpack Compose, SwiftUI). That is how an OpenVibe app can be a real
++native app and not a web page in a frame (the owner, 2026-10-29). Colours and sizes come from
++[OpenVibe Tokens](tokens.md); this file adds what each component *is*.
++
++```js
++const components = require('openvibe-shared/components'); // in a page: /shared/components.js → OpenVibeComponents
++components.SPECS // [{ id, name, summary, role, anatomy, props, variants, states, a11y, layout, native, web }]
++components.get('card') // one spec
++components.validate(doc) // a kit document → { ok, errors }
++components.render(doc) // → HTML through kit.js (an invalid document throws)
++components.PREAMBLE // what the native reference views share: { compose, swiftui }
++```
++
++## A component's spec
++
++| Field | What it says |
++| --- | --- |
++| `summary`, `role` | What it is for, and its accessibility role (a link, a region named by its heading, a disclosure…) |
++| `anatomy` | Its parts and the tokens each wears: `{ part: 'title', tokens: { color: 'color.text.primary', weight: 'type.weight.bold' } }`. Every token named exists in OpenVibe Tokens (tested) |
++| `props` | Typed: `string`, `text`, `url` (https or same-site only), `boolean`, `number`, `icon`, one of a list, `link` ({ href, label }), `count`, `stats`, `tabs`; `required` where it is |
++| `variants`, `states` | e.g. a button's `primary` / `quiet` / `danger`; default, hover, pressed, focused, disabled; a list's empty and loading |
++| `a11y` | Its name, its keys, its touch target (44 px) |
++| `layout` | How it behaves on a phone, a tablet and a computer |
++| `web` | The kit.js builder that draws it |
++| `native.compose`, `native.swiftui` | A reference view built on the same tokens: Material 3 roles from the theme's colour scheme and `OvScale` in Compose; `OvTokens` (the theme's enum) and `OvScale` in SwiftUI. Every name they use exists in the generated theme files (tested) |
++
++The components are button, card, rail, row, list, section, fold, tabs, numbers (stats), notice and empty state.
++
++## Kit documents (server-driven UI)
++
++A screen can be sent as data: a tree of `{ type, props, children }`. The web renders it with `components.render`; a
++native app renders the same document with its own views, so a server can change a screen without a release. This is
++the pattern of Airbnb's Ghost Platform and Yandex's DivKit, on the kit's own components.
++
++```json
++{ "type": "section", "props": { "title": "Right now" }, "children": [
++ { "type": "rail", "props": { "id": "now", "title": "Live", "more": { "href": "/live", "label": "See all" } }, "children": [
++ { "type": "card", "props": { "href": "/c/1", "title": "Night walk", "type": "stream", "live": true, "who": "goosely" } } ] } ] }
++```
++
++`validate` is strict:
++- only known types and props;
++- typed values, with enums and required props;
++- only the children a component takes (a rail takes cards, a list takes rows, sections and folds take anything);
++- at most 8 levels, 400 nodes and 60 children;
++- strings of at most 2,000 characters;
++- links that are https or same-site.
++
++`render` refuses an invalid document and goes through kit.js, which escapes every text. A document from anywhere
++therefore renders safely, and it renders exactly as the direct builder calls would (tested).
++
++## Native
++
++`components.PREAMBLE.compose` and `.swiftui` hold what an app adds once: the kinds (`OvKind`…) and, on iOS, which
++theme's tokens it wears (`typealias OvTokens = VibeDarkTokens`). The theme files themselves come from
++`openvibe.design/tokens` (OpenVibe Tokens' Compose and SwiftUI targets), and the scales are `OvScale` in both.
++OpenVibe.Design's `/components` page shows every component's spec, its web rendering and its Android and iOS views.
++
++Status: the reference views follow the Compose Material 3 and SwiftUI APIs. They are checked by review and by the
++tests' names, not compiled: this host has no Kotlin or Swift toolchain, and a native CI job is the next step.
ddiff --git a/files.js b/files.js
iindex dedace3..9f54524 100644
---- a/files.js
++++ b/files.js
@@@ -44,6 +44,7 @@ const BROWSER = Object.freeze([
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
'kit-data-web.js', // Kit 2's behaviour: chart readouts, scatter labels, drop zones (4.37.0; with kit-data.css)
'tokens.js', // OpenVibe Tokens: a theme as DTCG tokens, compiled to CSS, Tailwind, Compose, SwiftUI, Flutter, Figma (4.40.0)
++ 'components.js', // the kit's components as a contract (anatomy, tokens, states, a11y), kit documents, native reference views (4.42.0)
'search.js', // search everything from the navbar's magnifier, / or Ctrl+K; navbar.js loads it on first use
'loading.js', // the one loading look: a poster-backed overlay with status and progress, and skeleton rows
'agent-session.js', // a coding agent's session as people read it: steps grouped, live output, diffs, helpers, hand-offs
ddiff --git a/package-lock.json b/package-lock.json
iindex 8bc61c0..4d9060c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.41.0",
++ "version": "4.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.41.0",
++ "version": "4.42.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 0ef5b12..f759f43 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.41.0",
++ "version": "4.42.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -64,6 +64,7 @@
"./kit-web": "./kit-web.js",
"./kit-data-web": "./kit-data-web.js",
"./tokens": "./tokens.js",
++ "./components": "./components.js",
"./appbar": "./appbar.js",
"./search": "./search.js",
"./agent-session": "./agent-session.js",
ddiff --git a/test/components.test.js b/test/components.test.js
nnew file mode 100644
iindex 0000000..f8ca33f
---- /dev/null
++++ b/test/components.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++// components.js: the kit's components as a contract (anatomy, tokens, props, states, a11y) with reference views for
++// Compose and SwiftUI, and kit documents that validate strictly and render through kit.js exactly as a direct call would.
++const assert = require('assert');
++const components = require('../components');
++const kit = require('../kit');
++const tokens = require('../tokens');
++const { BUILTIN_THEMES } = require('../builtin-themes');
++
++const vibe = tokens.fromTheme(BUILTIN_THEMES.find((t) => t.slug === 'vibe'));
++const paths = new Set(tokens.flatten(vibe).map((t) => t.path.join('.')));
++const kt = tokens.compile(vibe, 'compose');
++const swift = tokens.compile(vibe, 'swiftui');
++const ktScale = new Set([...kt.slice(kt.indexOf('object OvScale')).matchAll(/val (\w+) =/g)].map((m) => m[1]));
++const swScale = new Set([...swift.slice(swift.indexOf('public enum OvScale')).matchAll(/static let (\w+):/g)].map((m) => m[1]));
++const swTokens = new Set([...swift.matchAll(/static let (\w+) = Color/g)].map((m) => m[1]));
++
++// ── The contract ──
++assert.ok(components.SPECS.length >= 11);
++for (const spec of components.SPECS) {
++ for (const k of ['id', 'name', 'summary', 'role', 'anatomy', 'props', 'states', 'a11y', 'layout', 'native', 'web']) assert.ok(spec[k], `${spec.id}: ${k}`);
++ assert.strictEqual(typeof kit[spec.web.replace('kit.', '')], 'function', `${spec.id}: its web builder ${spec.web} exists`);
++ for (const p of components.tokensOf(spec)) assert.ok(paths.has(p), `${spec.id}: token ${p} exists in OpenVibe Tokens`);
++ assert.ok(spec.native.compose.includes('@Composable') && /fun Ov\w+\(/.test(spec.native.compose), `${spec.id}: a Compose view`);
++ assert.ok(/struct Ov\w+(<[^>]+>)?: View/.test(spec.native.swiftui), `${spec.id}: a SwiftUI view`);
++ for (const m of spec.native.compose.matchAll(/OvScale\.(\w+)/g)) assert.ok(ktScale.has(m[1]), `${spec.id}: Compose OvScale.${m[1]} exists in the tokens' Compose file`);
++ for (const m of spec.native.swiftui.matchAll(/OvScale\.(\w+)/g)) assert.ok(swScale.has(m[1]), `${spec.id}: Swift OvScale.${m[1]} exists`);
++ for (const m of spec.native.swiftui.matchAll(/OvTokens\.(\w+)/g)) assert.ok(swTokens.has(m[1]), `${spec.id}: Swift OvTokens.${m[1]} exists on the theme enum`);
++ assert.ok(!/OvTokens\./.test(spec.native.compose), `${spec.id}: Compose reads MaterialTheme roles, not a theme object`);
++}
++assert.match(components.PREAMBLE.compose, /enum class OvKind/);
++assert.match(components.PREAMBLE.swiftui, /typealias OvTokens = VibeDarkTokens/);
++console.log(`✓ ${components.SPECS.length} components: anatomy tokens exist, web builders exist, Compose and SwiftUI views use real tokens`);
++
++// ── Kit documents render exactly as the builders do ──
++const doc = { type: 'section', props: { title: 'Right now', note: 'Who is live' }, children: [
++ { type: 'rail', props: { id: 'now', title: 'Live', more: { href: '/live', label: 'See all' }, wrap: true }, children: [
++ { type: 'card', props: { href: '/c/1', title: 'Night walk', type: 'stream', live: true, who: 'goosely', count: [1204, 'watching'] } },
++ { type: 'card', props: { href: '/c/2', title: 'Surf race', type: 'stream', sub: 'Utopia · 3 laps' } }] },
++ { type: 'stats', props: { items: [[26, 'services'], [106, 'capabilities', '/caps']] } },
++ { type: 'list', props: { title: 'Recent' }, children: [{ type: 'row', props: { href: 'https://example.com/', rel: 'nofollow ugc', title: 'A link', sub: 'example.com' } }] },
++ { type: 'fold', props: { title: 'More', meta: 'two' }, children: [{ type: 'notice', props: { kind: 'ok', text: 'Saved' } }, { type: 'button', props: { label: 'Go', href: '/go' } }] },
++] };
++const v = components.validate(doc);
++assert.ok(v.ok, v.errors.join('\n'));
++const html = components.render(doc, kit);
++const direct = kit.section({ title: 'Right now', note: 'Who is live', body:
++ kit.rail({ id: 'now', title: 'Live', more: { href: '/live', label: 'See all' }, wrap: true, items: [
++ kit.card({ href: '/c/1', title: 'Night walk', type: 'stream', live: true, who: 'goosely', count: [1204, 'watching'], when: null }),
++ kit.card({ href: '/c/2', title: 'Surf race', type: 'stream', sub: 'Utopia · 3 laps', when: null })] })
++ + kit.stats([[26, 'services'], [106, 'capabilities', '/caps']], { label: 'In numbers' })
++ + kit.list([kit.row({ href: 'https://example.com/', rel: 'nofollow ugc', title: 'A link', sub: 'example.com', mark: '' })], { title: 'Recent', more: null, flush: false, empty: '' })
++ + kit.fold({ title: 'More', meta: 'two', body: kit.notice({ kind: 'ok', text: 'Saved' }) + kit.button({ href: '/go', type: 'button', label: 'Go', kind: 'primary' }) }) });
++assert.strictEqual(html, direct, 'a document renders exactly as the direct builder calls');
++assert.match(html, /ovk-rail--wrap/);
++console.log('✓ a kit document renders exactly as kit.js does');
++
++// ── Strict validation ──
++const bad = (d, re) => { const r = components.validate(d); assert.ok(!r.ok && r.errors.some((e) => re.test(e)), `${JSON.stringify(d).slice(0, 80)} → ${r.errors.join(' | ')}`); assert.throws(() => components.render(d, kit), /invalid kit document/); };
++bad({ type: 'marquee' }, /unknown type/);
++bad({ type: 'card', props: { title: 'x' } }, /card\.props\.href: required/);
++bad({ type: 'card', props: { href: 'javascript:alert(1)', title: 'x' } }, /card\.props\.href: expected url/);
++bad({ type: 'card', props: { href: '//evil.example/', title: 'x' } }, /expected url/);
++bad({ type: 'card', props: { href: '/x', title: 'x', onclick: 'alert(1)' } }, /onclick: not a prop of card/);
++bad({ type: 'button', props: { label: 'x', kind: 'shiny' } }, /kind: expected "primary" \| "quiet" \| "danger"/);
++bad({ type: 'button', props: { label: 'x' }, children: [{ type: 'notice', props: { text: 'y' } }] }, /takes no children/);
++bad({ type: 'rail', children: [{ type: 'row', props: { title: 'r' } }] }, /rail takes card, not row/);
++bad({ type: 'stats', props: { items: [[1, 'a', 'javascript:x']] } }, /items: expected stats/);
++bad({ type: 'notice', props: { text: 'x'.repeat(2001) } }, /text: expected string/);
++let deep = { type: 'notice', props: { text: 'deep' } };
++for (let i = 0; i < 9; i++) deep = { type: 'section', props: { title: `l${i}` }, children: [deep] };
++bad(deep, /deeper than 8/);
++bad({ type: 'section', children: Array.from({ length: 61 }, () => ({ type: 'notice', props: { text: 'n' } })) }, /more than 60/);
++// Text is escaped by kit.js: a document's words can never become markup.
++const x = components.render({ type: 'card', props: { href: '/a', title: '<img src=x onerror=alert(1)>', sub: '"><script>alert(1)</script>' } }, kit);
++assert.throws(() => components.render({ type: 'notice', props: { text: 'x' } }), /pass kit\.js/, 'on a server the kit is passed in');
++assert.ok(!/<img src=x|<script>/.test(x) && /&lt;img src=x/.test(x), 'titles are escaped');
++console.log('✓ validation is strict (types, enums, required, children, depth, size, links) and text is escaped');
++console.log('components: all checks passed');
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex d9e1678..f2be9d2 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -43,14 +43,15 @@ assert.match(kt, /val ColorBgBase = Color\(0xFF0A0F1C\)/);
assert.match(kt, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);
assert.match(kt, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);
assert.match(kt, /primary = VibeDarkTokens\.ColorAccentBase,/);
--assert.match(kt, /val RadiusMd = 14\.dp/);
++assert.match(kt, /object OvScale \{[\s\S]*val RadiusMd = 14\.dp/, 'the scales are theme-independent: OvScale');
++assert.doesNotMatch(kt.slice(0, kt.indexOf('object OvScale')), /RadiusMd/, 'not on the theme objects');
assert.match(kt, /val TypeSizeBody = 15\.sp/);
const swift = tokens.compile(vibe, 'swiftui');
assert.match(swift, /^import SwiftUI$/m);
assert.match(swift, /public enum VibeTokens \{/);
assert.match(swift, /public static let colorScheme: ColorScheme = \.dark/);
assert.match(swift, /public static let colorAccentBase = Color\(\.sRGB, red: 0\.231, green: 0\.51, blue: 0\.965, opacity: 1\)/);
--assert.match(swift, /public static let motionDurationFast: Double = 0\.15/);
++assert.match(swift, /public enum OvScale \{[\s\S]*public static let motionDurationFast: Double = 0\.15/);
const dart = tokens.compile(vibe, 'flutter');
assert.match(dart, /static const colorBgBase = Color\(0xFF0A0F1C\);/);
assert.match(dart, /const vibeDarkScheme = ColorScheme\(\n {2}brightness: Brightness\.dark,/);
ddiff --git a/tokens.js b/tokens.js
iindex 83ab99a..98f6e5f 100644
---- a/tokens.js
++++ b/tokens.js
@@@ -291,21 +291,22 @@
const obj = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
parts.push(`/** ${commentText(doc.$description)} */`, `object ${obj} {`);
for (const t of colorTokens(doc)) parts.push(` val ${pascal(t.path)} = Color(${argb(t.c)})`);
-- if (doc === docs[0]) {
-- for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {
-- const n = num(t.value);
-- if (n != null) parts.push(` val ${pascal(t.path)} = ${n}.${t.path[0] === 'type' ? 'sp' : 'dp'}`);
-- }
-- for (const t of flatten(doc).filter((x) => x.type === 'duration' || x.type === 'fontWeight')) {
-- const n = num(t.value);
-- if (n != null) parts.push(` const val ${pascal(t.path)} = ${Math.round(n)}`);
-- }
-- }
parts.push('}', '');
const fn = m.mode === 'light' ? 'lightColorScheme' : 'darkColorScheme';
const roles = M3_ROLES.filter(([, path]) => pick(doc, path)).map(([role, path]) => ` ${role} = ${obj}.${pascal(path.split('.'))},`);
parts.push(`/** Material 3 roles: MaterialTheme(colorScheme = ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme) { … } */`, `val ${name}${m.mode === 'light' ? 'Light' : 'Dark'}ColorScheme: ColorScheme = ${fn}(`, ...roles, ')', '');
}
++ // The kit's scales: the same on every theme (openvibe-shared/components' reference views use OvScale).
++ parts.push('/** The OpenVibe kit\'s radius, space, size, type and motion scales: the same in every theme. */', 'object OvScale {');
++ for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) {
++ const n = num(t.value);
++ if (n != null) parts.push(` val ${pascal(t.path)} = ${n}.${t.path[0] === 'type' ? 'sp' : 'dp'}`);
++ }
++ for (const t of flatten(docs[0]).filter((x) => x.type === 'duration' || x.type === 'fontWeight')) {
++ const n = num(t.value);
++ if (n != null) parts.push(` const val ${pascal(t.path)} = ${Math.round(n)}`);
++ }
++ parts.push('}', '');
return parts.join('\n');
}
@@@ -319,18 +320,18 @@
const en = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
parts.push(`/// ${commentText(doc.$description)}`, `public enum ${en} {`, ` public static let colorScheme: ColorScheme = .${m.mode === 'light' ? 'light' : 'dark'}`);
for (const t of colorTokens(doc)) parts.push(` public static let ${camel(t.path)} = Color(.sRGB, red: ${f(t.c.r)}, green: ${f(t.c.g)}, blue: ${f(t.c.b)}, opacity: ${t.c.a})`);
-- if (doc === docs[0]) {
-- for (const t of flatten(doc).filter((x) => x.type === 'dimension')) {
-- const n = num(t.value);
-- if (n != null) parts.push(` public static let ${camel(t.path)}: CGFloat = ${n}`);
-- }
-- for (const t of flatten(doc).filter((x) => x.type === 'duration')) {
-- const n = num(t.value);
-- if (n != null) parts.push(` public static let ${camel(t.path)}: Double = ${n / 1000}`);
-- }
-- }
parts.push('}', '');
}
++ parts.push('/// The OpenVibe kit\'s radius, space, size, type and motion scales: the same in every theme.', 'public enum OvScale {');
++ for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) {
++ const n = num(t.value);
++ if (n != null) parts.push(` public static let ${camel(t.path)}: CGFloat = ${n}`);
++ }
++ for (const t of flatten(docs[0]).filter((x) => x.type === 'duration')) {
++ const n = num(t.value);
++ if (n != null) parts.push(` public static let ${camel(t.path)}: Double = ${n / 1000}`);
++ }
++ parts.push('}', '');
return parts.join('\n');
}
@@@ -343,11 +344,13 @@
const cls = `${name}${docs.length > 1 ? (m.mode === 'light' ? 'Light' : 'Dark') : ''}Tokens`;
parts.push(`/// ${commentText(doc.$description)}`, `class ${cls} {`, ` ${cls}._();`);
for (const t of colorTokens(doc)) parts.push(` static const ${camel(t.path)} = Color(${argb(t.c)});`);
-- if (doc === docs[0]) for (const t of flatten(doc).filter((x) => x.type === 'dimension')) { const n = num(t.value); if (n != null) parts.push(` static const double ${camel(t.path)} = ${n};`); }
parts.push('}', '');
const roles = M3_ROLES.filter(([role, path]) => pick(doc, path) && role !== 'surfaceVariant').map(([role, path]) => ` ${role}: ${cls}.${camel(path.split('.'))},`);
parts.push(`const ${name[0].toLowerCase() + name.slice(1)}${m.mode === 'light' ? 'Light' : 'Dark'}Scheme = ColorScheme(`, ` brightness: Brightness.${m.mode === 'light' ? 'light' : 'dark'},`, ...roles, ');', '');
}
++ parts.push('/// The OpenVibe kit\'s radius, space, size and type scales: the same in every theme.', 'class OvScale {', ' OvScale._();');
++ for (const t of flatten(docs[0]).filter((x) => x.type === 'dimension')) { const n = num(t.value); if (n != null) parts.push(` static const double ${camel(t.path)} = ${n};`); }
++ parts.push('}', '');
return parts.join('\n');
}