openvibers/openvibe.shared components.js

components.js

main 47.8 KB · 697 lines · javascript

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 'use strict';
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
59fun 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
113fun 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
168fun 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
216fun 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
258fun 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
289fun 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
327fun 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
368fun 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
401fun 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
443fun 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
490fun 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
531fun 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
570fun 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) { … }
594enum class OvKind { Primary, Quiet, Danger }
595enum 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.
597typealias OvTokens = VibeDarkTokens // or VibeLightTokens, from @Environment(\\.colorScheme)
598enum 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