openvibers/openvibe.shared components.js
components.js
| 1 | /** |
| 2 | * openvibe-shared/components.js — the OpenVibe kit's components as a contract that is not HTML, so the web, Android and |
| 3 | * iOS can each draw them natively (the owner, 2026-10-29: "translate it to official and optimized Android and iOS apps … |
| 4 | * that aren't just web browser reskin kiosks"). |
| 5 | * |
| 6 | * Every component is described by data: what it is for, its parts (anatomy) and the design tokens each part wears |
| 7 | * (openvibe-shared/tokens' names), its props, variants, states, accessibility and how it behaves on a phone, a tablet and |
| 8 | * a computer. The web draws it with kit.js today; `native` holds a reference view for Jetpack Compose and for SwiftUI |
| 9 | * that uses the same tokens (Material 3 roles from the theme's colour scheme, OvScale for the scales). |
| 10 | * |
| 11 | * A screen can then be sent as a **kit document**, a JSON tree of components that any client renders with its own |
| 12 | * views (the server-driven UI pattern of Airbnb's Ghost Platform and Yandex's DivKit): |
| 13 | * |
| 14 | * { type: 'section', props: { title: 'Right now' }, children: [ |
| 15 | * { type: 'rail', props: { id: 'now', title: 'Live', more: { href: '/live', label: 'See all' } }, children: [ |
| 16 | * { type: 'card', props: { href: '/c/1', title: 'Night walk', type: 'stream', live: true, who: 'goosely' } } ] } ] } |
| 17 | * |
| 18 | * const components = require('openvibe-shared/components'); // in a page: /shared/components.js → OpenVibeComponents |
| 19 | * components.SPECS the contract: [{ id, name, summary, role, anatomy, props, variants, … }] |
| 20 | * components.get(id) one spec |
| 21 | * components.validate(doc) → { ok, errors: ['card.props.title: required', …] } |
| 22 | * components.render(doc, kit) → HTML through kit.js (refuses an invalid document); kit defaults to window.OpenVibeKit |
| 23 | * |
| 24 | * validate() is strict: known types and props only, typed values, enums, required props, which children a component |
| 25 | * takes, at most 8 levels and 400 nodes, strings at most 2,000 characters. render() goes through kit.js, which escapes |
| 26 | * every text and keeps only http(s) or same-site links, so a document from anywhere renders safely. |
| 27 | */ |
| 28 | (function (root, factory) { |
| 29 | const api = factory(root); |
| 30 | if (typeof module === 'object' && module.exports) { module.exports = api; return; } |
| 31 | root.OpenVibeComponents = api; |
| 32 | })(typeof globalThis !== 'undefined' ? globalThis : this, function (root) { |
| 33 | ; |
| 34 | |
| 35 | const VERSION = 1; |
| 36 | const LIMITS = Object.freeze({ depth: 8, nodes: 400, string: 2000, items: 60 }); |
| 37 | |
| 38 | // Prop types: 'string', 'text' (long string), 'url', 'boolean', 'number', 'icon', ['a', 'b'] (one of), 'link' |
| 39 | // ({ href, label }), 'count' ([n, 'word'] or n), 'stats' ([[value, label, href?]…]), 'tabs' ([{ key, label, count }]). |
| 40 | const STATES = { press: ['default', 'hover', 'pressed', 'focused', 'disabled'], read: ['default', 'empty', 'loading'] }; |
| 41 | |
| 42 | const SPECS = [ |
| 43 | { |
| 44 | id: 'button', name: 'Button', web: 'kit.button', |
| 45 | 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.', |
| 46 | role: 'button (link when it has href)', |
| 47 | anatomy: [ |
| 48 | { part: 'container', tokens: { background: 'color.accent.strong', radius: 'radius.pill', minHeight: 'size.tap', paddingX: 'space.lg' } }, |
| 49 | { part: 'label', tokens: { color: 'color.accent.onStrong', size: 'type.size.body', weight: 'type.weight.bold' } }, |
| 50 | { part: 'icon', optional: true, tokens: { size: 'size.icon', color: 'color.accent.onStrong' } }, |
| 51 | ], |
| 52 | props: { label: { type: 'string', required: true }, href: { type: 'url' }, icon: { type: 'icon' }, kind: { type: ['primary', 'quiet', 'danger'], default: 'primary' }, small: { type: 'boolean' } }, |
| 53 | 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' } }, |
| 54 | states: STATES.press, |
| 55 | a11y: { name: 'its label', keys: 'Enter (and Space as a button)', minTarget: '44 × 44 on touch' }, |
| 56 | layout: { phone: 'full width in a form\'s last row; two side by side in a hero', computer: 'its own width' }, |
| 57 | native: { |
| 58 | compose: `@Composable |
| 59 | fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary, icon: ImageVector? = null) { |
| 60 | val c = MaterialTheme.colorScheme |
| 61 | val colors = when (kind) { |
| 62 | OvKind.Primary -> ButtonDefaults.buttonColors(containerColor = c.primaryContainer, contentColor = c.onPrimaryContainer) |
| 63 | OvKind.Danger -> ButtonDefaults.buttonColors(containerColor = c.error, contentColor = c.onError) |
| 64 | OvKind.Quiet -> ButtonDefaults.outlinedButtonColors(contentColor = c.onSurface) |
| 65 | } |
| 66 | val content: @Composable RowScope.() -> Unit = { |
| 67 | if (icon != null) { Icon(icon, contentDescription = null, Modifier.size(OvScale.SizeIcon)); Spacer(Modifier.width(OvScale.SpaceSm)) } |
| 68 | Text(label, fontWeight = FontWeight.Bold) |
| 69 | } |
| 70 | val m = Modifier.heightIn(min = OvScale.SizeTap) |
| 71 | if (kind == OvKind.Quiet) OutlinedButton(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, border = BorderStroke(1.dp, c.outline), content = content) |
| 72 | else Button(onClick, m, shape = RoundedCornerShape(OvScale.RadiusPill), colors = colors, content = content) |
| 73 | }`, |
| 74 | swiftui: `struct OvButton: View { |
| 75 | let label: String |
| 76 | var kind: OvKind = .primary |
| 77 | var icon: String? = nil |
| 78 | let action: () -> Void |
| 79 | var body: some View { |
| 80 | Button(action: action) { |
| 81 | Label { Text(label).fontWeight(.bold) } icon: { if let icon { Image(systemName: icon) } } |
| 82 | .frame(minHeight: OvScale.sizeTap) |
| 83 | .padding(.horizontal, OvScale.spaceLg) |
| 84 | } |
| 85 | .buttonStyle(.borderedProminent) |
| 86 | .buttonBorderShape(.capsule) |
| 87 | .tint(kind == .danger ? OvTokens.colorStatusBadBase : kind == .quiet ? Color.clear : OvTokens.colorAccentStrong) |
| 88 | .foregroundStyle(kind == .quiet ? OvTokens.colorTextPrimary : OvTokens.colorAccentOnStrong) |
| 89 | } |
| 90 | }`, |
| 91 | }, |
| 92 | }, |
| 93 | { |
| 94 | id: 'card', name: 'Card', web: 'kit.card', |
| 95 | summary: 'One thing, the same anatomy everywhere: a stream, a clip, a listing, a game, a paste. The whole card is one link.', |
| 96 | role: 'article with a heading link', |
| 97 | anatomy: [ |
| 98 | { 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' }, |
| 99 | { part: 'badge', optional: true, tokens: { background: 'rgba(0,0,0,.74)', color: '#fff' }, note: 'top left, one word; LIVE is color.status.live.base' }, |
| 100 | { part: 'corner', optional: true, note: 'bottom right: a length or a price' }, |
| 101 | { part: 'title', tokens: { color: 'color.text.primary', size: 'type.size.body', weight: 'type.weight.bold' }, note: 'two lines at most' }, |
| 102 | { part: 'sub', optional: true, tokens: { color: 'color.text.secondary', size: 'type.size.meta' }, note: 'one plain line' }, |
| 103 | { 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' }, |
| 104 | { part: 'more', optional: true, note: 'the ⋯ menu: Save, Remind, Share' }, |
| 105 | ], |
| 106 | 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' } }, |
| 107 | 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' } }, |
| 108 | states: ['default', 'hover (the picture zooms, the peek shows)', 'focused', 'live'], |
| 109 | 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' }, |
| 110 | layout: { phone: 'about 1.2 to a rail, swiped', computer: 'a fixed width in a rail, or a grid' }, |
| 111 | native: { |
| 112 | compose: `@Composable |
| 113 | fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Float = 16f / 9f, badge: String? = null, sub: String? = null, meta: String? = null) { |
| 114 | val c = MaterialTheme.colorScheme |
| 115 | Column(Modifier.clickable(onClickLabel = title, onClick = onOpen).semantics(mergeDescendants = true) { heading() }) { |
| 116 | Box(Modifier.fillMaxWidth().aspectRatio(ratio).clip(RoundedCornerShape(OvScale.RadiusMd)).background(c.surfaceVariant)) { |
| 117 | if (image != null) AsyncImage(image, contentDescription = null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) |
| 118 | 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) |
| 119 | } |
| 120 | Spacer(Modifier.height(OvScale.SpaceMd)) |
| 121 | Text(title, color = c.onSurface, fontWeight = FontWeight.Bold, fontSize = OvScale.TypeSizeBody, maxLines = 2, overflow = TextOverflow.Ellipsis) |
| 122 | if (sub != null) Text(sub, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1) |
| 123 | if (meta != null) Text(meta, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1) |
| 124 | } |
| 125 | }`, |
| 126 | swiftui: `struct OvCard: View { |
| 127 | let title: String |
| 128 | var image: URL? = nil |
| 129 | var ratio: CGFloat = 16.0 / 9.0 |
| 130 | var badge: String? = nil |
| 131 | var sub: String? = nil |
| 132 | var meta: String? = nil |
| 133 | var body: some View { |
| 134 | VStack(alignment: .leading, spacing: 2) { |
| 135 | ZStack(alignment: .topLeading) { |
| 136 | RoundedRectangle(cornerRadius: OvScale.radiusMd).fill(OvTokens.colorBgHover) |
| 137 | if let image { AsyncImage(url: image) { $0.resizable().scaledToFill() } placeholder: { Color.clear } } |
| 138 | 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) } |
| 139 | } |
| 140 | .aspectRatio(ratio, contentMode: .fit).clipShape(.rect(cornerRadius: OvScale.radiusMd)) |
| 141 | .padding(.bottom, OvScale.spaceSm) |
| 142 | Text(title).font(.system(size: OvScale.typeSizeBody, weight: .bold)).foregroundStyle(OvTokens.colorTextPrimary).lineLimit(2) |
| 143 | if let sub { Text(sub).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextSecondary).lineLimit(1) } |
| 144 | if let meta { Text(meta).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted).lineLimit(1) } |
| 145 | } |
| 146 | .accessibilityElement(children: .combine).accessibilityAddTraits(.isLink) |
| 147 | } |
| 148 | }`, |
| 149 | }, |
| 150 | }, |
| 151 | { |
| 152 | id: 'rail', name: 'Rail', web: 'kit.rail', |
| 153 | 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.', |
| 154 | role: 'region named by its heading; a list of cards', |
| 155 | children: ['card'], |
| 156 | anatomy: [ |
| 157 | { part: 'head', tokens: { size: 'type.size.lead', weight: 'type.weight.heavy' }, note: 'the title and See all (44 px to the touch)' }, |
| 158 | { part: 'track', tokens: { gap: 'space.md' }, note: 'snaps to each card; bleeds to the screen\'s edge on a phone' }, |
| 159 | { part: 'arrows', optional: true, note: '‹ › on computers only, each hidden at its end' }, |
| 160 | ], |
| 161 | props: { id: { type: 'string' }, title: { type: 'string' }, more: { type: 'link' }, size: { type: ['s', 'm', 'l'], default: 'm' }, wrap: { type: 'boolean' }, label: { type: 'string' } }, |
| 162 | variants: { size: { s: 'square things (memes, deals), 190 px', m: '16:9 pictures, 280 px', l: 'wide features, 400 px' } }, |
| 163 | states: STATES.read, |
| 164 | a11y: { name: 'its title', keys: 'Tab moves card to card; the arrows are buttons', minTarget: 'See all 44 px' }, |
| 165 | layout: { phone: 'about 1.2 cards showing, swiped', tablet: 'as many as fit', computer: 'arrows, or a grid with wrap' }, |
| 166 | native: { |
| 167 | compose: `@Composable |
| 168 | fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.() -> Unit) { |
| 169 | Column(Modifier.semantics { heading() }) { |
| 170 | Row(Modifier.fillMaxWidth().padding(horizontal = OvScale.SpaceLg), verticalAlignment = Alignment.CenterVertically) { |
| 171 | Text(title, Modifier.weight(1f), fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeLead) |
| 172 | if (seeAll != null) TextButton(seeAll, Modifier.heightIn(min = OvScale.SizeTap)) { Text("See all") } |
| 173 | } |
| 174 | val state = rememberLazyListState() |
| 175 | LazyRow(state = state, flingBehavior = rememberSnapFlingBehavior(state), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg), |
| 176 | horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceMd), content = content) |
| 177 | } |
| 178 | }`, |
| 179 | swiftui: `struct OvRail<Content: View>: View { |
| 180 | let title: String |
| 181 | var seeAll: (() -> Void)? = nil |
| 182 | @ViewBuilder var content: () -> Content |
| 183 | var body: some View { |
| 184 | VStack(alignment: .leading, spacing: OvScale.spaceMd) { |
| 185 | HStack { |
| 186 | Text(title).font(.system(size: OvScale.typeSizeLead, weight: .heavy)).accessibilityAddTraits(.isHeader) |
| 187 | Spacer() |
| 188 | if let seeAll { Button("See all", action: seeAll).frame(minHeight: OvScale.sizeTap) } |
| 189 | }.padding(.horizontal, OvScale.spaceLg) |
| 190 | ScrollView(.horizontal, showsIndicators: false) { |
| 191 | LazyHStack(spacing: OvScale.spaceMd) { content() }.scrollTargetLayout().padding(.horizontal, OvScale.spaceLg) |
| 192 | }.scrollTargetBehavior(.viewAligned) |
| 193 | } |
| 194 | } |
| 195 | }`, |
| 196 | }, |
| 197 | }, |
| 198 | { |
| 199 | id: 'row', name: 'Row', web: 'kit.row', |
| 200 | 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.', |
| 201 | role: 'listitem (a link when it has href)', |
| 202 | anatomy: [ |
| 203 | { part: 'mark', optional: true, tokens: { size: 'size.avatar' }, note: 'a picture, or up to two letters on a colour from the name' }, |
| 204 | { part: 'title', tokens: { color: 'color.text.primary', weight: 'type.weight.bold' } }, |
| 205 | { part: 'meta', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' }, note: 'the time, on the title\'s line' }, |
| 206 | { part: 'sub', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' }, note: 'one line, cut with …' }, |
| 207 | { part: 'trail', optional: true, note: 'a count, a switch or a button' }, |
| 208 | ], |
| 209 | props: { href: { type: 'url' }, rel: { type: 'string' }, title: { type: 'string', required: true }, meta: { type: 'string' }, sub: { type: 'string' }, mark: { type: 'string' }, unread: { type: 'number' } }, |
| 210 | variants: { unread: { '0': 'plain', 'n': 'bold title and a dot with the count' } }, |
| 211 | states: STATES.press, |
| 212 | a11y: { name: 'its title and sub-line', keys: 'Enter opens it', minTarget: 'the row, at least 56 px tall' }, |
| 213 | layout: { phone: 'full width, divided by lines', computer: 'the same, in a panel' }, |
| 214 | native: { |
| 215 | compose: `@Composable |
| 216 | fun OvRow(title: String, onOpen: (() -> Unit)? = null, sub: String? = null, meta: String? = null, mark: (@Composable () -> Unit)? = null, trail: (@Composable () -> Unit)? = null) { |
| 217 | ListItem( |
| 218 | headlineContent = { Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) }, |
| 219 | supportingContent = sub?.let { { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = OvScale.TypeSizeMeta) } }, |
| 220 | leadingContent = mark, trailingContent = trail ?: meta?.let { { Text(it, fontSize = OvScale.TypeSizeMeta) } }, |
| 221 | modifier = (if (onOpen != null) Modifier.clickable(onClick = onOpen) else Modifier).heightIn(min = 56.dp), |
| 222 | ) |
| 223 | }`, |
| 224 | swiftui: `struct OvRow: View { |
| 225 | let title: String |
| 226 | var sub: String? = nil |
| 227 | var meta: String? = nil |
| 228 | var body: some View { |
| 229 | HStack(alignment: .firstTextBaseline) { |
| 230 | VStack(alignment: .leading, spacing: 2) { |
| 231 | Text(title).fontWeight(.bold).lineLimit(1) |
| 232 | if let sub { Text(sub).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted).lineLimit(1) } |
| 233 | } |
| 234 | Spacer() |
| 235 | if let meta { Text(meta).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted) } |
| 236 | } |
| 237 | .frame(minHeight: 56) |
| 238 | .accessibilityElement(children: .combine) |
| 239 | } |
| 240 | }`, |
| 241 | }, |
| 242 | }, |
| 243 | { |
| 244 | id: 'list', name: 'List', web: 'kit.list', |
| 245 | 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.', |
| 246 | role: 'list', |
| 247 | children: ['row'], |
| 248 | anatomy: [ |
| 249 | { part: 'head', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' }, note: 'uppercase, with its link' }, |
| 250 | { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } }, |
| 251 | ], |
| 252 | props: { title: { type: 'string' }, more: { type: 'link' }, empty: { type: 'string' }, flush: { type: 'boolean' } }, |
| 253 | states: STATES.read, |
| 254 | a11y: { name: 'its heading', keys: 'Tab row to row', minTarget: 'rows 56 px' }, |
| 255 | layout: { phone: 'full width', computer: 'in its column' }, |
| 256 | native: { |
| 257 | compose: `@Composable |
| 258 | fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) { |
| 259 | Column { |
| 260 | if (title != null) Text(title.uppercase(), Modifier.padding(OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 261 | Surface(shape = RoundedCornerShape(OvScale.RadiusMd), tonalElevation = 1.dp) { Column(content = content) } |
| 262 | } |
| 263 | }`, |
| 264 | swiftui: `struct OvList<Content: View>: View { |
| 265 | var title: String? = nil |
| 266 | @ViewBuilder var content: () -> Content |
| 267 | var body: some View { |
| 268 | Section { content() } header: { if let title { Text(title).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)) } } |
| 269 | } |
| 270 | }`, |
| 271 | }, |
| 272 | }, |
| 273 | { |
| 274 | id: 'section', name: 'Section', web: 'kit.section', |
| 275 | 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.', |
| 276 | role: 'region named by its heading', |
| 277 | children: '*', |
| 278 | anatomy: [ |
| 279 | { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } }, |
| 280 | { part: 'title', tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' } }, |
| 281 | { part: 'note', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.meta' } }, |
| 282 | ], |
| 283 | props: { id: { type: 'string' }, title: { type: 'string' }, count: { type: 'number' }, note: { type: 'string' } }, |
| 284 | states: ['default'], |
| 285 | a11y: { name: 'its title', keys: 'none of its own', minTarget: '' }, |
| 286 | layout: { phone: 'full width', computer: 'in its column' }, |
| 287 | native: { |
| 288 | compose: `@Composable |
| 289 | fun OvSection(title: String, note: String? = null, content: @Composable ColumnScope.() -> Unit) { |
| 290 | Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) { |
| 291 | Column(Modifier.padding(vertical = OvScale.SpaceMd)) { |
| 292 | Text(title.uppercase(), Modifier.padding(horizontal = OvScale.SpaceMd).semantics { heading() }, fontSize = OvScale.TypeSizeCaption, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 293 | if (note != null) Text(note, Modifier.padding(horizontal = OvScale.SpaceMd), fontSize = OvScale.TypeSizeMeta, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 294 | content() |
| 295 | } |
| 296 | } |
| 297 | }`, |
| 298 | swiftui: `struct OvSection<Content: View>: View { |
| 299 | let title: String |
| 300 | var note: String? = nil |
| 301 | @ViewBuilder var content: () -> Content |
| 302 | var body: some View { |
| 303 | GroupBox { |
| 304 | VStack(alignment: .leading) { content() } |
| 305 | } label: { |
| 306 | VStack(alignment: .leading) { Text(title.uppercased()).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)); if let note { Text(note).font(.system(size: OvScale.typeSizeMeta)) } } |
| 307 | } |
| 308 | } |
| 309 | }`, |
| 310 | }, |
| 311 | }, |
| 312 | { |
| 313 | id: 'fold', name: 'Fold', web: 'kit.fold', |
| 314 | summary: 'A card that opens and closes, for what is read once: setup, extras, details, the danger zone. Closed unless told.', |
| 315 | role: 'disclosure (details/summary)', |
| 316 | children: '*', |
| 317 | anatomy: [ |
| 318 | { part: 'summary', tokens: { minHeight: 'size.tap', weight: 'type.weight.bold' }, note: 'the title, a muted word after it, a chevron that turns' }, |
| 319 | { part: 'body', tokens: { padding: 'space.md' } }, |
| 320 | ], |
| 321 | props: { id: { type: 'string' }, title: { type: 'string', required: true }, meta: { type: 'string' }, open: { type: 'boolean' }, tone: { type: ['', 'danger'] } }, |
| 322 | states: ['closed', 'open', 'focused'], |
| 323 | a11y: { name: 'its title', keys: 'Enter or Space opens and closes', minTarget: '44 px' }, |
| 324 | layout: { phone: 'full width', computer: 'in its column' }, |
| 325 | native: { |
| 326 | compose: `@Composable |
| 327 | fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false, content: @Composable () -> Unit) { |
| 328 | var open by rememberSaveable { mutableStateOf(initiallyOpen) } |
| 329 | Surface(shape = RoundedCornerShape(OvScale.RadiusMd)) { |
| 330 | Column { |
| 331 | Row(Modifier.fillMaxWidth().heightIn(min = OvScale.SizeTap).clickable(onClickLabel = if (open) "Close" else "Open") { open = !open }.padding(horizontal = OvScale.SpaceMd), |
| 332 | verticalAlignment = Alignment.CenterVertically) { |
| 333 | Text(title, Modifier.weight(1f), fontWeight = FontWeight.Bold) |
| 334 | if (meta != null) Text(meta, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 335 | Icon(Icons.Default.ExpandMore, null, Modifier.rotate(if (open) 180f else 0f)) |
| 336 | } |
| 337 | AnimatedVisibility(open) { Box(Modifier.padding(OvScale.SpaceMd)) { content() } } |
| 338 | } |
| 339 | } |
| 340 | }`, |
| 341 | swiftui: `struct OvFold<Content: View>: View { |
| 342 | let title: String |
| 343 | var meta: String? = nil |
| 344 | @State var open = false |
| 345 | @ViewBuilder var content: () -> Content |
| 346 | var body: some View { |
| 347 | DisclosureGroup(isExpanded: $open) { content().padding(.top, OvScale.spaceSm) } label: { |
| 348 | HStack { Text(title).fontWeight(.bold); if let meta { Text(meta).foregroundStyle(OvTokens.colorTextMuted) } }.frame(minHeight: OvScale.sizeTap) |
| 349 | } |
| 350 | } |
| 351 | }`, |
| 352 | }, |
| 353 | }, |
| 354 | { |
| 355 | id: 'tabs', name: 'Tabs', web: 'kit.tabs', |
| 356 | 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.', |
| 357 | role: 'navigation; the open tab is aria-current', |
| 358 | anatomy: [ |
| 359 | { 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' }, |
| 360 | { part: 'count', optional: true, tokens: { size: 'type.size.caption', weight: 'type.weight.heavy' } }, |
| 361 | ], |
| 362 | props: { base: { type: 'url' }, items: { type: 'tabs', required: true }, active: { type: 'string' }, param: { type: 'string' }, label: { type: 'string' } }, |
| 363 | states: ['default', 'current', 'focused'], |
| 364 | a11y: { name: 'its label (Sections)', keys: 'Tab between links; Enter opens', minTarget: '44 px on touch' }, |
| 365 | layout: { phone: 'one strip that scrolls sideways', computer: 'in a row' }, |
| 366 | native: { |
| 367 | compose: `@Composable |
| 368 | fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) { |
| 369 | ScrollableTabRow(selectedTabIndex = selected, edgePadding = OvScale.SpaceLg) { |
| 370 | tabs.forEachIndexed { i, t -> Tab(selected = i == selected, onClick = { onSelect(i) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), text = { Text(t) }) } |
| 371 | } |
| 372 | }`, |
| 373 | swiftui: `struct OvTabs: View { |
| 374 | let tabs: [String] |
| 375 | @Binding var selected: Int |
| 376 | var body: some View { |
| 377 | Picker("Sections", selection: $selected) { ForEach(tabs.indices, id: \\.self) { Text(tabs[$0]).tag($0) } } |
| 378 | .pickerStyle(.segmented) |
| 379 | } |
| 380 | }`, |
| 381 | }, |
| 382 | }, |
| 383 | { |
| 384 | id: 'sidebar', name: 'Sidebar', web: 'kit.sidebar', |
| 385 | summary: "An app's left sidebar: the main action and search on top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), then its sections. A column beside the page on a computer, a drawer on a phone.", |
| 386 | role: 'complementary, with the navigation as a nav; the page you are on is aria-current', |
| 387 | anatomy: [ |
| 388 | { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base' }, note: '272 px wide on a computer; a drawer of up to 320 px on a phone' }, |
| 389 | { part: 'action', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.primary', icon: 'color.accent.base' }, note: 'the one thing to start: New chat, Go live, New post' }, |
| 390 | { part: 'link', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.muted', icon: 'size.icon' }, note: 'the page you are on: color.accent.base icon on a tint of it, color.text.primary' }, |
| 391 | { part: 'count', optional: true, tokens: { background: 'color.accent.strong', size: 'type.size.caption', weight: 'type.weight.heavy', radius: 'radius.pill' } }, |
| 392 | { part: 'section heading', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' }, note: 'uppercase' }, |
| 393 | { part: 'item', optional: true, tokens: { color: 'color.text.primary', size: 'type.size.body', hover: 'color.bg.hover' }, note: 'one line, a mark before it and a time or a dot after it' }, |
| 394 | ], |
| 395 | props: { id: { type: 'string' }, label: { type: 'string' }, action: { type: 'link' }, nav: { type: 'navs', required: true } }, |
| 396 | states: ['default', 'current', 'focused', 'open'], |
| 397 | a11y: { name: 'its label (the product)', keys: 'Tab down the rows; Enter opens; Escape closes the drawer and focus returns', minTarget: 'rows 38 px, 44 px with their gap on touch' }, |
| 398 | layout: { phone: 'a drawer over the page, opened from the bar, closed by the scrim, its close button, Escape or a page move', computer: 'a column beside the page; its sections scroll on their own' }, |
| 399 | native: { |
| 400 | compose: `@Composable |
| 401 | fun OvSidebar(items: List<Pair<String, ImageVector>>, current: Int, onPick: (Int) -> Unit, drawer: DrawerState, content: @Composable () -> Unit) { |
| 402 | ModalNavigationDrawer(drawerState = drawer, drawerContent = { |
| 403 | ModalDrawerSheet(Modifier.widthIn(max = 320.dp)) { |
| 404 | Column(Modifier.padding(OvScale.SpaceMd)) { |
| 405 | items.forEachIndexed { i, (label, icon) -> |
| 406 | NavigationDrawerItem(label = { Text(label) }, icon = { Icon(icon, null, Modifier.size(OvScale.SizeIcon)) }, selected = i == current, |
| 407 | onClick = { onPick(i) }, shape = RoundedCornerShape(OvScale.RadiusMd), modifier = Modifier.heightIn(min = OvScale.SizeTap)) |
| 408 | } |
| 409 | } |
| 410 | } |
| 411 | }, content = content) |
| 412 | }`, |
| 413 | swiftui: `struct OvSidebar<Detail: View>: View { |
| 414 | let items: [(label: String, icon: String)] |
| 415 | @Binding var current: Int? |
| 416 | @ViewBuilder var detail: () -> Detail |
| 417 | var body: some View { |
| 418 | NavigationSplitView { |
| 419 | List(selection: $current) { |
| 420 | ForEach(items.indices, id: \\.self) { i in Label(items[i].label, systemImage: items[i].icon).frame(minHeight: OvScale.sizeTap).tag(i) } |
| 421 | } |
| 422 | .listStyle(.sidebar) |
| 423 | } detail: { detail() } |
| 424 | } |
| 425 | }`, |
| 426 | }, |
| 427 | }, |
| 428 | { |
| 429 | id: 'stats', name: 'Numbers', web: 'kit.stats', |
| 430 | summary: 'A strip of real numbers; zero and unknown values are left out. A number with a link makes its whole tile the link.', |
| 431 | role: 'description list (label: value)', |
| 432 | anatomy: [ |
| 433 | { part: 'tile', tokens: { background: 'color.bg.raised', line: 'color.line.base', radius: 'radius.md' } }, |
| 434 | { part: 'value', tokens: { color: 'color.text.primary', size: 'type.size.title', weight: 'type.weight.heavy' }, note: 'tabular figures' }, |
| 435 | { part: 'label', tokens: { color: 'color.text.muted', size: 'type.size.caption' } }, |
| 436 | ], |
| 437 | props: { items: { type: 'stats', required: true }, label: { type: 'string' } }, |
| 438 | states: ['default'], |
| 439 | a11y: { name: 'each label with its value', keys: 'a linked tile is one Tab stop', minTarget: 'the tile' }, |
| 440 | layout: { phone: 'one strip that scrolls sideways', tablet: 'a grid of 130 px tiles', computer: 'the same' }, |
| 441 | native: { |
| 442 | compose: `@Composable |
| 443 | fun OvStats(items: List<Pair<String, String>>) { |
| 444 | LazyRow(horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg)) { |
| 445 | items(items) { (value, label) -> |
| 446 | Surface(shape = RoundedCornerShape(OvScale.RadiusMd), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) { |
| 447 | Column(Modifier.widthIn(min = 104.dp).padding(horizontal = 14.dp, vertical = 8.dp).semantics(mergeDescendants = true) {}) { |
| 448 | Text(value, fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeTitle, style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum")) |
| 449 | Text(label, fontSize = OvScale.TypeSizeCaption, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| 450 | } |
| 451 | } |
| 452 | } |
| 453 | } |
| 454 | }`, |
| 455 | swiftui: `struct OvStats: View { |
| 456 | let items: [(value: String, label: String)] |
| 457 | var body: some View { |
| 458 | ScrollView(.horizontal, showsIndicators: false) { |
| 459 | HStack(spacing: OvScale.spaceSm) { |
| 460 | ForEach(items.indices, id: \\.self) { i in |
| 461 | VStack(alignment: .leading) { |
| 462 | Text(items[i].value).font(.system(size: OvScale.typeSizeTitle, weight: .heavy)).monospacedDigit() |
| 463 | Text(items[i].label).font(.system(size: OvScale.typeSizeCaption)).foregroundStyle(OvTokens.colorTextMuted) |
| 464 | } |
| 465 | .padding(.horizontal, 14).padding(.vertical, 8).frame(minWidth: 104, alignment: .leading) |
| 466 | .background(OvTokens.colorBgRaised, in: .rect(cornerRadius: OvScale.radiusMd)) |
| 467 | .accessibilityElement(children: .combine) |
| 468 | } |
| 469 | }.padding(.horizontal, OvScale.spaceLg) |
| 470 | } |
| 471 | } |
| 472 | }`, |
| 473 | }, |
| 474 | }, |
| 475 | { |
| 476 | id: 'notice', name: 'Notice', web: 'kit.notice', |
| 477 | summary: 'A line in the page that tells the person what happened: ok, info, warn, or an error (announced at once).', |
| 478 | role: 'status, or alert for errors', |
| 479 | anatomy: [ |
| 480 | { part: 'icon', tokens: { size: 'size.icon' } }, |
| 481 | { part: 'text', tokens: { color: 'color.text.primary' } }, |
| 482 | ], |
| 483 | props: { kind: { type: ['ok', 'info', 'warn', 'error'], default: 'info' }, text: { type: 'string', required: true } }, |
| 484 | variants: { kind: { ok: 'color.status.ok.text', info: 'color.status.info.text', warn: 'color.status.warn.text', error: 'color.status.bad.text, role alert' } }, |
| 485 | states: ['default'], |
| 486 | a11y: { name: 'its text', keys: 'none; errors are announced', minTarget: '' }, |
| 487 | layout: { phone: 'full width', computer: 'in its column' }, |
| 488 | native: { |
| 489 | compose: `@Composable |
| 490 | fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) { |
| 491 | val c = MaterialTheme.colorScheme |
| 492 | val tint = when (kind) { OvNoticeKind.Error -> c.error; OvNoticeKind.Ok -> c.primary; else -> c.tertiary } |
| 493 | Row(Modifier.fillMaxWidth().semantics { liveRegion = if (kind == OvNoticeKind.Error) LiveRegionMode.Assertive else LiveRegionMode.Polite } |
| 494 | .background(tint.copy(alpha = .12f), RoundedCornerShape(OvScale.RadiusSm)).padding(OvScale.SpaceMd), horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) { |
| 495 | Icon(if (kind == OvNoticeKind.Error) Icons.Default.Error else Icons.Default.Info, null, tint = tint) |
| 496 | Text(text, color = c.onSurface) |
| 497 | } |
| 498 | }`, |
| 499 | swiftui: `struct OvNotice: View { |
| 500 | let text: String |
| 501 | var isError = false |
| 502 | var body: some View { |
| 503 | Label(text, systemImage: isError ? "exclamationmark.triangle" : "info.circle") |
| 504 | .padding(OvScale.spaceMd).frame(maxWidth: .infinity, alignment: .leading) |
| 505 | .background((isError ? OvTokens.colorStatusBadText : OvTokens.colorStatusInfoText).opacity(0.12), in: .rect(cornerRadius: OvScale.radiusSm)) |
| 506 | .accessibilityAddTraits(isError ? [.updatesFrequently] : []) |
| 507 | } |
| 508 | }`, |
| 509 | }, |
| 510 | }, |
| 511 | { |
| 512 | id: 'share', name: 'Share', web: 'kit.share', |
| 513 | summary: 'The one Share button and the sheet it opens, on every site: send the link (copy, the phone\'s own share sheet, the places people post, a QR code, embed, start at a time) and, for its owner, who can open it.', |
| 514 | role: 'button that opens a modal sheet', |
| 515 | children: null, |
| 516 | anatomy: [ |
| 517 | { part: 'button', tokens: { minHeight: 'size.tap', weight: 'type.weight.bold' }, note: 'Share, with the up-from-a-tray icon' }, |
| 518 | { part: 'sheet', tokens: { surface: 'color.bg.raised', radius: 'radius.md', padding: 'space.lg' }, note: 'from the bottom on a phone, a 520 px dialog on a computer; Escape, the backdrop and Done close it' }, |
| 519 | { part: 'link', tokens: { surface: 'color.bg.hover', text: 'color.text.primary', action: 'color.accent.strong', onAction: 'color.accent.onStrong' }, note: 'the link without https:// and Copy, which turns into a tick' }, |
| 520 | { part: 'device', optional: true, tokens: { minHeight: 'size.tap' }, note: 'phones: "Send with your phone", the platform\'s own sheet with its contacts' }, |
| 521 | { part: 'places', tokens: { size: 'size.avatar', label: 'type.size.meta' }, note: 'round brand tiles, the person\'s recent ones first, the rest behind More (n)' }, |
| 522 | { part: 'panel', optional: true, tokens: { line: 'color.line.base' }, note: 'QR code, embed code or a Mastodon server, under the places' }, |
| 523 | { part: 'access', optional: true, tokens: { muted: 'color.text.muted' }, note: 'the owner only: Everyone · Anyone with the link · Specific people · Only me, the people, Transfer' }, |
| 524 | ], |
| 525 | props: { title: { type: 'string', required: true }, url: { type: 'url' }, text: { type: 'string' }, image: { type: 'url' }, embed: { type: 'url' }, kind: { type: 'string' }, id: { type: 'string' }, label: { type: 'string' }, small: { type: 'boolean' } }, |
| 526 | states: ['closed', 'open', 'copied', 'panel open', 'saving', 'not the owner (send only)'], |
| 527 | a11y: { name: '"Share", and the sheet is named "Share <title>"', keys: 'Enter opens; Tab moves; Escape closes (asking first when there are unsaved changes)', minTarget: '44 px' }, |
| 528 | layout: { phone: 'a sheet from the bottom, the home bar cleared, Send with your phone first, four places a row', computer: 'a dialog, six places a row' }, |
| 529 | native: { |
| 530 | compose: `@Composable |
| 531 | fun OvShare(title: String, url: String, label: String = "Share", onShare: (title: String, url: String) -> Unit) { |
| 532 | // The app hands the link to the platform's own sheet, with Direct Share's contacts. On Android: |
| 533 | // context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) }, title)) |
| 534 | OutlinedButton(onClick = { onShare(title, url) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), shape = RoundedCornerShape(OvScale.RadiusPill)) { |
| 535 | Icon(Icons.Default.Share, contentDescription = null) |
| 536 | Spacer(Modifier.width(OvScale.SpaceSm)) |
| 537 | Text(label, fontWeight = FontWeight.Bold) |
| 538 | } |
| 539 | }`, |
| 540 | swiftui: `struct OvShare: View { |
| 541 | let title: String |
| 542 | let url: URL |
| 543 | var label = "Share" |
| 544 | var body: some View { |
| 545 | // ShareLink is the system sheet (contacts, Messages, AirDrop, apps); the OpenVibe sheet adds QR, embed and access. |
| 546 | ShareLink(item: url, subject: Text(title), message: Text(title)) { |
| 547 | Label(label, systemImage: "square.and.arrow.up").fontWeight(.bold).frame(minHeight: OvScale.sizeTap) |
| 548 | } |
| 549 | .buttonStyle(.bordered).clipShape(RoundedRectangle(cornerRadius: OvScale.radiusMd)) |
| 550 | } |
| 551 | }`, |
| 552 | }, |
| 553 | }, |
| 554 | { |
| 555 | id: 'empty', name: 'Empty state', web: 'kit.empty', |
| 556 | summary: 'What an empty list says: a title, one line, and the one thing to do next.', |
| 557 | role: 'status', |
| 558 | anatomy: [ |
| 559 | { part: 'icon', optional: true, tokens: { color: 'color.accent.base' } }, |
| 560 | { part: 'title', tokens: { weight: 'type.weight.bold' } }, |
| 561 | { part: 'text', optional: true, tokens: { color: 'color.text.muted' } }, |
| 562 | { part: 'action', optional: true, note: 'a primary button' }, |
| 563 | ], |
| 564 | props: { title: { type: 'string', required: true }, text: { type: 'string' }, icon: { type: 'icon' }, action: { type: 'link' } }, |
| 565 | states: ['default'], |
| 566 | a11y: { name: 'its title and line', keys: 'the action is one Tab stop', minTarget: '44 px' }, |
| 567 | layout: { phone: 'centred in the space', computer: 'the same' }, |
| 568 | native: { |
| 569 | compose: `@Composable |
| 570 | fun OvEmpty(title: String, text: String? = null, action: Pair<String, () -> Unit>? = null) { |
| 571 | Column(Modifier.fillMaxWidth().padding(OvScale.SpaceXl), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(OvScale.SpaceSm)) { |
| 572 | Text(title, fontWeight = FontWeight.Bold) |
| 573 | if (text != null) Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) |
| 574 | if (action != null) OvButton(action.first, action.second) |
| 575 | } |
| 576 | }`, |
| 577 | swiftui: `struct OvEmpty: View { |
| 578 | let title: String |
| 579 | var text: String? = nil |
| 580 | var body: some View { |
| 581 | ContentUnavailableView { Text(title).fontWeight(.bold) } description: { if let text { Text(text) } } |
| 582 | } |
| 583 | }`, |
| 584 | }, |
| 585 | }, |
| 586 | ]; |
| 587 | |
| 588 | // What the reference views share, once per app: the kinds, and (iOS) which theme's tokens the app wears. The theme |
| 589 | // files come from openvibe-shared/tokens (OpenVibe.Design /tokens): Compose reads MaterialTheme's colour scheme |
| 590 | // (VibeDarkColorScheme…) and OvScale; SwiftUI reads OvTokens (an alias of the theme's enum) and OvScale. |
| 591 | const PREAMBLE = Object.freeze({ |
| 592 | compose: `// OpenVibe kit for Jetpack Compose: add the theme file from openvibe.design/tokens (Android) and wrap the app in |
| 593 | // MaterialTheme(colorScheme = if (isSystemInDarkTheme()) VibeDarkColorScheme else VibeLightColorScheme) { … } |
| 594 | enum class OvKind { Primary, Quiet, Danger } |
| 595 | enum class OvNoticeKind { Ok, Info, Warn, Error }`, |
| 596 | swiftui: `// OpenVibe kit for SwiftUI: add the theme file from openvibe.design/tokens (iOS) and say which half the app wears. |
| 597 | typealias OvTokens = VibeDarkTokens // or VibeLightTokens, from @Environment(\\.colorScheme) |
| 598 | enum OvKind { case primary, quiet, danger }`, |
| 599 | }); |
| 600 | |
| 601 | const byId = new Map(SPECS.map((c) => [c.id, c])); |
| 602 | const get = (id) => byId.get(String(id)) || null; |
| 603 | |
| 604 | // ── Validation ── |
| 605 | const isStr = (v) => typeof v === 'string' && v.length <= LIMITS.string; |
| 606 | const isUrl = (v) => isStr(v) && /^(https:\/\/[^\s"'<>]+|\/(?!\/)[^\s"'<>]*|#[\w-]*)$/.test(v.trim()); |
| 607 | function checkType(type, v) { |
| 608 | if (Array.isArray(type)) return type.includes(v); |
| 609 | switch (type) { |
| 610 | case 'string': case 'text': case 'icon': return isStr(v); |
| 611 | case 'url': return isUrl(v); |
| 612 | case 'boolean': return typeof v === 'boolean'; |
| 613 | case 'number': return typeof v === 'number' && Number.isFinite(v); |
| 614 | case 'link': return !!v && typeof v === 'object' && isUrl(v.href) && (v.label == null || isStr(v.label)); |
| 615 | 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]))); |
| 616 | 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]))); |
| 617 | 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))); |
| 618 | case 'navs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isUrl(x.href) && isStr(x.label) && (x.icon == null || isStr(x.icon)) && (x.here == null || typeof x.here === 'boolean') && (x.count == null || Number.isFinite(Number(x.count)))); |
| 619 | default: return false; |
| 620 | } |
| 621 | } |
| 622 | /** A kit document against the contract → { ok, errors }. */ |
| 623 | function validate(doc) { |
| 624 | const errors = []; |
| 625 | let nodes = 0; |
| 626 | (function walk(node, at, depth) { |
| 627 | if (errors.length > 50) return; |
| 628 | nodes++; |
| 629 | if (nodes > LIMITS.nodes) { if (nodes === LIMITS.nodes + 1) errors.push(`more than ${LIMITS.nodes} nodes`); return; } |
| 630 | if (depth > LIMITS.depth) { errors.push(`${at}: deeper than ${LIMITS.depth}`); return; } |
| 631 | if (!node || typeof node !== 'object' || Array.isArray(node)) { errors.push(`${at}: not a component`); return; } |
| 632 | const spec = get(node.type); |
| 633 | if (!spec) { errors.push(`${at}: unknown type ${JSON.stringify(String(node.type)).slice(0, 40)}`); return; } |
| 634 | const here = `${at === '$' ? '' : `${at}.`}${spec.id}`; |
| 635 | const props = node.props == null ? {} : node.props; |
| 636 | if (typeof props !== 'object' || Array.isArray(props)) { errors.push(`${here}.props: not an object`); return; } |
| 637 | for (const k of Object.keys(props)) { |
| 638 | const p = spec.props[k]; |
| 639 | if (!p) errors.push(`${here}.props.${k}: not a prop of ${spec.id}`); |
| 640 | 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}`); |
| 641 | } |
| 642 | for (const [k, p] of Object.entries(spec.props)) if (p.required && (props[k] == null || props[k] === '')) errors.push(`${here}.props.${k}: required`); |
| 643 | const kids = node.children == null ? [] : node.children; |
| 644 | if (!Array.isArray(kids)) { errors.push(`${here}.children: not a list`); return; } |
| 645 | if (kids.length && !spec.children) errors.push(`${here}: takes no children`); |
| 646 | if (kids.length > LIMITS.items) errors.push(`${here}.children: more than ${LIMITS.items}`); |
| 647 | kids.slice(0, LIMITS.items).forEach((c, i) => { |
| 648 | 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)}`); |
| 649 | walk(c, `${here}[${i}]`, depth + 1); |
| 650 | }); |
| 651 | })(doc, '$', 1); |
| 652 | return { ok: errors.length === 0, errors }; |
| 653 | } |
| 654 | |
| 655 | // ── Rendering for the web, through kit.js ── |
| 656 | /** |
| 657 | * A valid kit document as HTML (the kit's own builders: escaped, links checked). An invalid one throws. kit: kit.js — |
| 658 | * in a page, window.OpenVibeKit (the default); on a server, require('openvibe-shared/kit'). |
| 659 | */ |
| 660 | function render(doc, kitLib = root && root.OpenVibeKit) { |
| 661 | 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)'); |
| 662 | const v = validate(doc); |
| 663 | if (!v.ok) throw Object.assign(new Error(`invalid kit document: ${v.errors.slice(0, 3).join('; ')}`), { errors: v.errors }); |
| 664 | const kit = kitLib; |
| 665 | const draw = (n) => { |
| 666 | const p = n.props || {}; |
| 667 | const kids = (n.children || []).map(draw); |
| 668 | switch (n.type) { |
| 669 | 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 }); |
| 670 | case 'card': return kit.card({ ...p, when: p.when || null }); |
| 671 | case 'rail': return kit.rail({ ...p, items: kids }); |
| 672 | case 'row': return kit.row({ ...p, mark: p.mark ? kit.mark({ name: p.mark }) : '' }); |
| 673 | case 'list': return kit.list(kids, { title: p.title || '', more: p.more || null, flush: !!p.flush, empty: p.empty || '' }); |
| 674 | 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('') }); |
| 675 | case 'fold': return kit.fold({ id: p.id || null, title: p.title, meta: p.meta || '', open: !!p.open, tone: p.tone || '', body: kids.join('') }); |
| 676 | case 'tabs': return kit.tabs({ base: p.base || '', items: p.items, active: p.active || '', param: p.param || 'tab', label: p.label || 'Sections' }); |
| 677 | case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) }); |
| 678 | case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' }); |
| 679 | case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text }); |
| 680 | case 'share': return kit.share({ title: p.title, url: p.url || null, text: p.text || '', image: p.image || null, embed: p.embed || null, kind: p.kind || null, id: p.id || null, label: p.label || 'Share', small: !!p.small }); |
| 681 | 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 }); |
| 682 | default: return ''; |
| 683 | } |
| 684 | }; |
| 685 | return draw(doc); |
| 686 | } |
| 687 | |
| 688 | /** Every token path a spec names (for tests and docs). */ |
| 689 | function tokensOf(spec) { |
| 690 | const out = new Set(); |
| 691 | 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); |
| 692 | return [...out]; |
| 693 | } |
| 694 | |
| 695 | return { VERSION, LIMITS, SPECS, PREAMBLE, get, validate, render, tokensOf }; |
| 696 | }); |
| 697 |