openvibers/openvibe.shared Commits
4.47.0: the kit renders natively, on Android and iOS (#189)
Changes
@@@ -11,6 +11,7 @@ on:- 'components.js'- 'builtin-themes.js'- 'scripts/native-check.js'++ - 'scripts/native/**'- 'native/kotlin/**'- '.github/workflows/native.yml'push:@@@ -20,6 +21,7 @@ on:- 'components.js'- 'builtin-themes.js'- 'scripts/native-check.js'++ - 'scripts/native/**'- 'native/kotlin/**'- '.github/workflows/native.yml'@@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.47.0 — 2026-10-29++++- **The kit renders natively, on Android and iOS.** `scripts/native/compose/KitRenderer.kt` (`KitView(doc: KitNode)`, a++ Composable that parses the document with org.json) and `scripts/native/swiftui/KitRenderer.swift`++ (`KitView(doc: KitNode)`, a SwiftUI `View` that decodes it with Codable) draw a kit document with the kit's own++ reference views, wearing the generated tokens (the Material 3 colour scheme and `OvScale` in Compose, `OvTokens` and++ `OvScale` in SwiftUI). A tap comes back as an action, so the app decides what a link or a button does.++- A sample document (`scripts/native/sample-doc.json`: a hero, a card rail, a list, stats and a notice) passes++ `components.validate()`, and the native CI compiles the Kotlin against Compose and type-checks the Swift against++ SwiftUI together with the tokens and the reference views. A component added to `components.js` without a native++ view now fails the kit check, so the two languages cannot fall behind the web.++## 4.46.1 — 2026-10-29- `showcase.apps`: a set of six or nine is three a row (`sc-apps--c3`), on a phone and from 600 px. Four columns left one@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.46.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.47.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.46.1",++ "version": "4.47.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -63,3 +63,50 @@ Status: the reference views follow the Compose Material 3 and SwiftUI APIs, and`scripts/native-check.js` writes the preamble and every view out as Kotlin and Swift, and the native CI job(`.github/workflows/native.yml`) compiles the Kotlin with kotlinc against the Compose runtime and UI artifacts andtype-checks the Swift with swiftc against SwiftUI. A view that does not compile fails the build.++## Native renderers++++A kit document is not a web page in a frame: it is data, and every client draws it with its own views. Next to the++reference views, the kit ships two renderers that do exactly that:++++| File | What it is |++| --- | --- |++| `scripts/native/compose/KitRenderer.kt` | `KitView(doc: KitNode)`, a Composable that draws a kit document with the kit's Compose views |++| `scripts/native/swiftui/KitRenderer.swift` | `KitView(doc: KitNode)`, a SwiftUI `View` that draws the same document with the kit's SwiftUI views |++| `scripts/native/sample-doc.json` | A sample document: a hero, a card rail, a list, stats and a notice |++++An app embeds the renderer in one line:++++```kotlin++// Android++val doc = KitNode.parse(json) // org.json, as Android carries it++MaterialTheme(colorScheme = VibeDarkColorScheme) {++ KitView(doc) { action -> // a tap comes back as an action++ when (action) {++ is KitAction.Open -> open(action.href)++ is KitAction.Run -> run(action.id)++ }++ }++}++```++++```swift++// iOS++let doc = try JSONDecoder().decode(KitNode.self, from: data)++KitView(doc: doc) { action in // a tap comes back as an action++ switch action {++ case .open(let href): open(href)++ case .run(let id): run(id)++ }++}++```++++Both renderers wear the generated tokens — the Material 3 colour scheme and `OvScale` in Compose, `OvTokens` and++`OvScale` in SwiftUI — and both draw every component the validator accepts, by calling that component's reference++view. A component added to `components.js` without a native view fails the kit check, so the two languages never++fall behind the web.++++A kit document from Design (`/api/v1/components/validate`) renders the same on the web, on Android and on iOS: the++web through `components.render(doc, kit)`, the apps through `KitView(doc)`. The native CI++(`.github/workflows/native.yml`) compiles the Kotlin against Compose and type-checks the Swift against SwiftUI,++together with the tokens and the reference views, so a renderer that no longer compiles fails the build here and not++in an app.@@@ -61,6 +61,9 @@ dependencies {composeClasspath("androidx.compose.material3:material3:$material3Version")composeClasspath("androidx.compose.material:material-icons-extended:$composeVersion")composeClasspath("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")++ // The kit renderer's JSON reader. Android carries org.json in the platform; the check compiles against the++ // same class from Maven Central, so the renderer is checked against the parser it ships with.++ composeClasspath("org.json:json:20240303")composeCompilerPlugin("org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$kotlinVersion")@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.46.1",++ "version": "4.47.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.46.1",++ "version": "4.47.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.46.1",++ "version": "4.47.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -14,8 +14,10 @@* and the kit's scales (OvScale)* <out>/kotlin/OvKit.kt the kit's Compose reference views: components.js' preamble and every spec* <out>/kotlin/OvKitStubs.kt the check's stand-ins: the image loader an app brings (Coil, Glide)++ * <out>/kotlin/KitRenderer.kt the kit renderer: a kit document, drawn with those views* <out>/swift/VibeTokens.swift the theme's SwiftUI output: both halves (the dark and the light tokens)* <out>/swift/OvKit.swift the kit's SwiftUI reference views++ * <out>/swift/KitRenderer.swift the kit renderer: a kit document, drawn with those views* <out>/manifest.json what was written, and where it came from** The native CI job (.github/workflows/native.yml) compiles the Kotlin with kotlinc against the Compose runtime and@@@ -160,6 +162,18 @@ function swiftKit() {return `${parts.join('\n')}\n`;}++/**++ * The renderers: the kit's own sources, checked in under scripts/native/, copied into the folder the compiler++ * reads so they are compiled (Kotlin) and type-checked (Swift) with the tokens and the reference views.++ */++function renderers() {++ const dir = path.join(__dirname, 'native');++ return [++ { path: 'kotlin/KitRenderer.kt', text: fs.readFileSync(path.join(dir, 'compose', 'KitRenderer.kt'), 'utf8') },++ { path: 'swift/KitRenderer.swift', text: fs.readFileSync(path.join(dir, 'swiftui', 'KitRenderer.swift'), 'utf8') },++ ];++}++/*** The native sources, as { path, text }: the theme's Kotlin and Swift, and the kit's.* The theme is compiled as a pair (its dark half and its light half), so both colour schemes land in one file —@@@ -178,6 +192,7 @@ function sources() {{ path: 'kotlin/OvKitStubs.kt', text: COMPOSE_STUBS },{ path: `swift/${name}Tokens.swift`, text: tokens.compile(docs, 'swiftui') },{ path: 'swift/OvKit.swift', text: swiftKit() },++ ...renderers(),],};}@@@ -219,4 +234,4 @@ function main(argv) {if (require.main === module) process.exit(main(process.argv.slice(2)));--module.exports = { sources, write, themes, main };++module.exports = { sources, write, themes, renderers, main };nnew file mode 100644@@@ -0,0 +1,580 @@++// The OpenVibe kit for Jetpack Compose: a kit document, rendered.++//++// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with++// kit.js; this file draws the same document with the kit's own Compose views (OvKit.kt), wearing the theme's tokens++// (the Material 3 colour scheme and OvScale).++//++// val doc = KitNode.parse(json)++// MaterialTheme(colorScheme = VibeDarkColorScheme) {++// KitView(doc) { action -> open(action) }++// }++//++// The JSON is read with org.json, the parser Android carries in the platform.++package network.openvibe.kit++++import androidx.compose.foundation.layout.Column++import androidx.compose.foundation.layout.fillMaxWidth++import androidx.compose.foundation.layout.padding++import androidx.compose.foundation.lazy.items++import androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material3.MaterialTheme++import androidx.compose.material3.Surface++import androidx.compose.material3.Text++import androidx.compose.runtime.Composable++import androidx.compose.runtime.getValue++import androidx.compose.runtime.mutableStateOf++import androidx.compose.runtime.saveable.rememberSaveable++import androidx.compose.runtime.setValue++import androidx.compose.ui.Modifier++import androidx.compose.ui.graphics.vector.ImageVector++import androidx.compose.ui.text.font.FontWeight++import network.openvibe.theme.OvScale++import org.json.JSONArray++import org.json.JSONObject++++/** What a tap asks the app to do: open a link, or run the action a button names. */++sealed class KitAction {++ /** Follow a link (a card, a row, a button with an href). */++ data class Open(val href: String) : KitAction()++++ /** Run the action a button names (the app decides what that means). */++ data class Run(val id: String) : KitAction()++}++++/** A link the kit draws: where it goes and what it says. */++data class KitLink(val href: String, val label: String)++++/** One tab: its key (what the web puts in the URL), its label and its count. */++data class KitTab(val key: String, val label: String, val count: Int? = null)++++/** One number with its label. */++data class KitStat(val value: String, val label: String, val href: String? = null)++++/**++ * A node of a kit document: one component, with its props and its children.++ *++ * The sealed cases are the kit's components; every one of them has a reference view in OvKit.kt that this file++ * draws with. A new component in components.js needs a case here (and a view), or the kit check fails.++ */++sealed class KitNode {++ /** The component's type, as it is written in the document. */++ abstract val type: String++++ /** The component's children, in document order. */++ abstract val children: List<KitNode>++++ /** A button: one action, a link or a run. */++ data class Button(++ val label: String,++ val href: String? = null,++ val icon: String? = null,++ val kind: OvKind = OvKind.Primary,++ val small: Boolean = false,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "button"++ }++++ /** A card: one thing, the whole card is one link. */++ data class Card(++ val href: String,++ val title: String,++ val variant: String? = null,++ val ratio: Float? = null,++ val image: String? = null,++ val alt: String? = null,++ val badge: String? = null,++ val live: Boolean = false,++ val corner: String? = null,++ val sub: String? = null,++ val preview: String? = null,++ val who: String? = null,++ val avatar: String? = null,++ val count: String? = null,++ val shownAt: String? = null,++ val level: Int? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "card"++++ /** The media's shape, as the view takes it. */++ val ratioValue: Float get() = ratio ?: 16f / 9f++++ /** The line under the title: who, the count, when. */++ val metaText: String?++ get() = listOfNotNull(who, count, shownAt).joinToString(" · ").ifEmpty { null }++++ /** The badge: the one the document gives, or LIVE. */++ val badgeText: String? get() = badge ?: if (live) "LIVE" else null++ }++++ /** A rail: a row of cards that scrolls sideways. */++ data class Rail(++ val title: String,++ val id: String? = null,++ val more: KitLink? = null,++ val size: String = "m",++ val wrap: Boolean = false,++ val label: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "rail"++++ /** The cards the rail holds. */++ val cards: List<Card> get() = children.filterIsInstance<Card>()++ }++++ /** A row: one item in a list. */++ data class Row(++ val title: String,++ val href: String? = null,++ val rel: String? = null,++ val meta: String? = null,++ val sub: String? = null,++ val mark: String? = null,++ val unread: Int? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "row"++ }++++ /** A list: rows in one rounded panel. */++ data class ListPanel(++ val title: String? = null,++ val more: KitLink? = null,++ val empty: String? = null,++ val flush: Boolean = false,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "list"++++ /** The rows the list holds. */++ val rows: List<Row> get() = children.filterIsInstance<Row>()++ }++++ /** A section: a titled group of anything. */++ data class Section(++ val title: String,++ val id: String? = null,++ val count: Int? = null,++ val note: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "section"++ }++++ /** A fold: a group that opens and closes. */++ data class Fold(++ val title: String,++ val id: String? = null,++ val meta: String? = null,++ val open: Boolean = false,++ val tone: String? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "fold"++ }++++ /** Tabs: the sections of a screen, one at a time. */++ data class Tabs(++ val items: List<KitTab> = emptyList(),++ val base: String? = null,++ val active: String? = null,++ val param: String = "tab",++ val label: String = "Sections",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "tabs"++++ /** The tab that is open: the one the document names, or the first. */++ val selectedIndex: Int++ get() {++ val i = items.indexOfFirst { it.key == active }++ return if (i >= 0) i else 0++ }++ }++++ /** Stats: the numbers, in a row. */++ data class Stats(++ val items: List<KitStat> = emptyList(),++ val label: String = "In numbers",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "stats"++ }++++ /** A notice: one line that says how things stand. */++ data class Notice(++ val text: String,++ val kind: OvNoticeKind = OvNoticeKind.Info,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "notice"++ }++++ /** An empty state: what a screen shows when it has nothing to show. */++ data class Empty(++ val title: String,++ val text: String? = null,++ val icon: String? = null,++ val action: KitLink? = null,++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "empty"++ }++++ companion object {++ /** A document, as JSON, into its nodes. Throws when a node is not a component the kit knows. */++ fun parse(json: String): KitNode = parse(JSONObject(json))++++ /** A node, as JSON, into its component. Throws when the type is not one the kit knows. */++ fun parse(obj: JSONObject): KitNode {++ val type = obj.optString("type", "")++ val props = obj.optJSONObject("props") ?: JSONObject()++ val kids = childrenOf(obj.optJSONArray("children"))++ return when (type) {++ "button" -> Button(++ label = props.str("label") ?: "",++ href = props.str("href"),++ icon = props.str("icon"),++ kind = when (props.str("kind")) {++ "quiet" -> OvKind.Quiet++ "danger" -> OvKind.Danger++ else -> OvKind.Primary++ },++ small = props.bool("small"),++ )++ "card" -> Card(++ href = props.str("href") ?: "",++ title = props.str("title") ?: "",++ variant = props.str("type"),++ ratio = ratioOf(props.str("ratio")),++ image = props.str("image"),++ alt = props.str("alt"),++ badge = props.str("badge"),++ live = props.bool("live"),++ corner = props.str("corner"),++ sub = props.str("sub"),++ preview = props.str("preview"),++ who = props.str("who"),++ avatar = props.str("avatar"),++ count = props.count("count"),++ shownAt = props.str("when"),++ level = props.int("level"),++ )++ "rail" -> Rail(++ title = props.str("title") ?: "",++ id = props.str("id"),++ more = props.link("more"),++ size = props.str("size") ?: "m",++ wrap = props.bool("wrap"),++ label = props.str("label"),++ children = kids,++ )++ "row" -> Row(++ title = props.str("title") ?: "",++ href = props.str("href"),++ rel = props.str("rel"),++ meta = props.str("meta"),++ sub = props.str("sub"),++ mark = props.str("mark"),++ unread = props.int("unread"),++ )++ "list" -> ListPanel(++ title = props.str("title"),++ more = props.link("more"),++ empty = props.str("empty"),++ flush = props.bool("flush"),++ children = kids,++ )++ "section" -> Section(++ title = props.str("title") ?: "",++ id = props.str("id"),++ count = props.int("count"),++ note = props.str("note"),++ children = kids,++ )++ "fold" -> Fold(++ title = props.str("title") ?: "",++ id = props.str("id"),++ meta = props.str("meta"),++ open = props.bool("open"),++ tone = props.str("tone"),++ children = kids,++ )++ "tabs" -> Tabs(++ items = tabsOf(props.array("items")),++ base = props.str("base"),++ active = props.str("active"),++ param = props.str("param") ?: "tab",++ label = props.str("label") ?: "Sections",++ )++ "stats" -> Stats(++ items = statsOf(props.array("items")),++ label = props.str("label") ?: "In numbers",++ )++ "notice" -> Notice(++ text = props.str("text") ?: "",++ kind = when (props.str("kind")) {++ "ok" -> OvNoticeKind.Ok++ "warn" -> OvNoticeKind.Warn++ "error" -> OvNoticeKind.Error++ else -> OvNoticeKind.Info++ },++ )++ "empty" -> Empty(++ title = props.str("title") ?: "",++ text = props.str("text"),++ icon = props.str("icon"),++ action = props.link("action"),++ )++ else -> throw IllegalArgumentException("unknown kit component: " + type)++ }++ }++++ /** The children of a node, parsed. */++ private fun childrenOf(array: JSONArray?): List<KitNode> {++ if (array == null) return emptyList()++ val out = ArrayList<KitNode>()++ for (i in 0 until array.length()) {++ val child = array.optJSONObject(i) ?: continue++ out.add(parse(child))++ }++ return out++ }++++ /** The tabs of a tab row. */++ private fun tabsOf(array: JSONArray?): List<KitTab> {++ if (array == null) return emptyList()++ val out = ArrayList<KitTab>()++ for (i in 0 until array.length()) {++ val tab = array.optJSONObject(i) ?: continue++ val key = tab.str("key") ?: continue++ out.add(KitTab(key, tab.str("label") ?: key, tab.int("count")))++ }++ return out++ }++++ /** The numbers of a stats row. */++ private fun statsOf(array: JSONArray?): List<KitStat> {++ if (array == null) return emptyList()++ val out = ArrayList<KitStat>()++ for (i in 0 until array.length()) {++ val row = array.optJSONArray(i) ?: continue++ val value = row.optString(0, "")++ val label = row.optString(1, "")++ val href = if (row.length() > 2 && !row.isNull(2)) row.optString(2, "") else null++ out.add(KitStat(value, label, href))++ }++ return out++ }++ }++}++++// ── Reading the props ──++++/** A string prop, or nothing when the document does not carry it. */++private fun JSONObject.str(key: String): String? = if (has(key) && !isNull(key)) optString(key, "") else null++++/** A boolean prop. */++private fun JSONObject.bool(key: String, def: Boolean = false): Boolean =++ if (has(key) && !isNull(key)) optBoolean(key, def) else def++++/** A number prop. */++private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null++++/** An array prop. */++private fun JSONObject.array(key: String): JSONArray? = if (has(key) && !isNull(key)) optJSONArray(key) else null++++/** A link prop: { href, label }. */++private fun JSONObject.link(key: String): KitLink? {++ val obj = optJSONObject(key) ?: return null++ val href = obj.str("href") ?: return null++ return KitLink(href, obj.str("label") ?: href)++}++++/** A count prop: a number, or [number, word]. */++private fun JSONObject.count(key: String): String? {++ val value = opt(key) ?: return null++ return when (value) {++ is Number -> value.toString()++ is String -> value++ is JSONArray -> {++ val number = value.opt(0)++ val word = value.optString(1, "")++ if (number == null) null else if (word.isEmpty()) number.toString() else number.toString() + " " + word++ }++ else -> null++ }++}++++/** A ratio prop: "16:9" and the like, as the number the view takes. */++private fun ratioOf(raw: String?): Float? {++ if (raw == null) return null++ val parts = raw.split(":")++ if (parts.size == 2) {++ val a = parts[0].trim().toFloatOrNull()++ val b = parts[1].trim().toFloatOrNull()++ if (a != null && b != null && b != 0f) return a / b++ }++ return raw.trim().toFloatOrNull()++}++++// ── Drawing it ──++++/**++ * A kit document, drawn.++ *++ * The app wears the theme's Material 3 colour scheme around it (see the kit's preamble), and this draws every++ * component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button++ * does.++ */++@Composable++fun KitView(++ doc: KitNode,++ icons: Map<String, ImageVector> = emptyMap(),++ onAction: (KitAction) -> Unit = {},++) {++ Column(Modifier.fillMaxWidth()) {++ KitNodeView(doc, icons, onAction)++ }++}++++/** One node, drawn with the kit's view for its component. */++@Composable++fun KitNodeView(++ node: KitNode,++ icons: Map<String, ImageVector> = emptyMap(),++ onAction: (KitAction) -> Unit = {},++) {++ when (node) {++ is KitNode.Button -> OvButton(++ label = node.label,++ onClick = {++ val href = node.href++ if (href != null) onAction(KitAction.Open(href)) else onAction(KitAction.Run(node.label))++ },++ kind = node.kind,++ icon = node.icon?.let { icons[it] },++ )++ is KitNode.Card -> OvCard(++ title = node.title,++ onOpen = { onAction(KitAction.Open(node.href)) },++ image = node.image,++ ratio = node.ratioValue,++ badge = node.badgeText,++ sub = node.sub,++ meta = node.metaText,++ )++ is KitNode.Rail -> OvRail(++ title = node.title,++ seeAll = node.more?.let { more ->++ val href = more.href++ { onAction(KitAction.Open(href)) }++ },++ ) {++ items(node.cards) { card ->++ KitNodeView(card, icons, onAction)++ }++ }++ is KitNode.Row -> {++ val href = node.href++ val open: (() -> Unit)? = if (href != null) {++ val link = href++ { onAction(KitAction.Open(link)) }++ } else {++ null++ }++ val markName = node.mark++ val mark: (@Composable () -> Unit)? = if (markName != null) {++ val name = markName++ val slot: @Composable () -> Unit = { OvInitials(name) }++ slot++ } else {++ null++ }++ val unread = node.unread++ val trail: (@Composable () -> Unit)? = if (unread != null && unread > 0) {++ val count = unread++ val slot: @Composable () -> Unit = {++ Text(++ count.toString(),++ color = MaterialTheme.colorScheme.primary,++ fontWeight = FontWeight.Bold,++ fontSize = OvScale.TypeSizeMeta,++ )++ }++ slot++ } else {++ null++ }++ OvRow(++ title = node.title,++ onOpen = open,++ sub = node.sub,++ meta = node.meta,++ mark = mark,++ trail = trail,++ )++ }++ is KitNode.ListPanel -> OvList(title = node.title) {++ val rows = node.rows++ if (rows.isEmpty()) {++ val empty = node.empty++ if (empty != null) OvEmpty(empty)++ } else {++ rows.forEach { row ->++ KitNodeView(row, icons, onAction)++ }++ }++ }++ is KitNode.Section -> OvSection(title = node.title, note = node.note) {++ node.children.forEach { child ->++ KitNodeView(child, icons, onAction)++ }++ }++ is KitNode.Fold -> OvFold(title = node.title, meta = node.meta, initiallyOpen = node.open) {++ node.children.forEach { child ->++ KitNodeView(child, icons, onAction)++ }++ }++ is KitNode.Tabs -> {++ val labels = node.items.map { it.label }++ var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }++ OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })++ }++ is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })++ is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)++ is KitNode.Empty -> {++ val link = node.action++ val action: Pair<String, () -> Unit>? = if (link != null) {++ val href = link.href++ val label = link.label++ label to { onAction(KitAction.Open(href)) }++ } else {++ null++ }++ OvEmpty(title = node.title, text = node.text, action = action)++ }++ }++}++++/**++ * A row's mark: up to two letters on the theme's accent, when the document gives a name and no picture.++ */++@Composable++private fun OvInitials(name: String) {++ val letters = name.trim().take(2).uppercase()++ Surface(++ shape = RoundedCornerShape(OvScale.RadiusPill),++ color = MaterialTheme.colorScheme.primaryContainer,++ ) {++ Text(++ letters,++ Modifier.padding(horizontal = OvScale.SpaceSm, vertical = OvScale.SpaceXs),++ color = MaterialTheme.colorScheme.onPrimaryContainer,++ fontSize = OvScale.TypeSizeCaption,++ fontWeight = FontWeight.Bold,++ )++ }++}nnew file mode 100644@@@ -0,0 +1,156 @@++{++ "type": "section",++ "props": {++ "id": "hero",++ "title": "OpenVibe",++ "count": 5,++ "note": "What is happening right now"++ },++ "children": [++ {++ "type": "row",++ "props": {++ "title": "Night walk",++ "sub": "goosely walks the city, live",++ "meta": "2 min",++ "mark": "goosely"++ }++ },++ {++ "type": "button",++ "props": {++ "label": "Open the app",++ "href": "https://openvibe.net/app",++ "kind": "primary"++ }++ },++ {++ "type": "rail",++ "props": {++ "id": "now",++ "title": "Live",++ "more": {++ "href": "https://openvibe.net/live",++ "label": "See all"++ },++ "size": "m"++ },++ "children": [++ {++ "type": "card",++ "props": {++ "href": "https://openvibe.net/c/1",++ "title": "Night walk",++ "type": "stream",++ "ratio": "16:9",++ "badge": "LIVE",++ "live": true,++ "sub": "A city, at night",++ "who": "goosely",++ "count": [++ 128,++ "watching"++ ],++ "when": "2 min"++ }++ },++ {++ "type": "card",++ "props": {++ "href": "https://openvibe.net/c/2",++ "title": "Studio session",++ "type": "stream",++ "ratio": "16:9",++ "image": "https://openvibe.net/media/session.jpg",++ "sub": "Three songs, one take",++ "who": "mira",++ "count": 42,++ "when": "9 min"++ }++ },++ {++ "type": "card",++ "props": {++ "href": "https://openvibe.net/c/3",++ "title": "Meme of the day",++ "type": "listing",++ "ratio": "1:1",++ "sub": "It does not matter",++ "who": "kit",++ "count": [++ 7,++ "comments"++ ],++ "when": "1 h"++ }++ }++ ]++ },++ {++ "type": "list",++ "props": {++ "title": "Recently",++ "more": {++ "href": "https://openvibe.net/recent",++ "label": "All"++ },++ "empty": "Nothing yet"++ },++ "children": [++ {++ "type": "row",++ "props": {++ "href": "https://openvibe.net/r/1",++ "title": "The nightly show",++ "sub": "Started 20 minutes ago",++ "meta": "live"++ }++ },++ {++ "type": "row",++ "props": {++ "href": "https://openvibe.net/r/2",++ "title": "Ask Vibe anything",++ "sub": "A thread about the kit",++ "meta": "3 new"++ }++ },++ {++ "type": "row",++ "props": {++ "title": "A quiet row",++ "sub": "With nothing to open",++ "meta": "yesterday"++ }++ }++ ]++ },++ {++ "type": "stats",++ "props": {++ "label": "In numbers",++ "items": [++ [++ "12",++ "live now"++ ],++ [++ "1.4k",++ "watching"++ ],++ [++ "98",++ "rooms open"++ ]++ ]++ }++ },++ {++ "type": "notice",++ "props": {++ "kind": "info",++ "text": "The kit renders the same on the web, on Android and on iOS."++ }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,761 @@++// The OpenVibe kit for SwiftUI: a kit document, rendered.++//++// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with++// kit.js; this file draws the same document with the kit's own SwiftUI views (OvKit.swift), wearing the theme's++// tokens (OvTokens, OvScale).++//++// let doc = try JSONDecoder().decode(KitNode.self, from: data)++// KitView(doc: doc) { action in open(action) }++//++// The document is decoded with Codable, so an app can fetch it, cache it and decode it off the main thread.++import SwiftUI++import Foundation++++// ── The document's values ──++++/// A JSON value, as a kit document carries them.++public enum KitValue: Codable {++ case string(String)++ case bool(Bool)++ case int(Int)++ case double(Double)++ case array([KitValue])++ case object([String: KitValue])++ case null++++ public init(from decoder: Decoder) throws {++ let container = try decoder.singleValueContainer()++ if container.decodeNil() {++ self = .null++ } else if let value = try? container.decode(Bool.self) {++ self = .bool(value)++ } else if let value = try? container.decode(Int.self) {++ self = .int(value)++ } else if let value = try? container.decode(Double.self) {++ self = .double(value)++ } else if let value = try? container.decode(String.self) {++ self = .string(value)++ } else if let value = try? container.decode([KitValue].self) {++ self = .array(value)++ } else {++ self = .object(try container.decode([String: KitValue].self))++ }++ }++++ public func encode(to encoder: Encoder) throws {++ var container = encoder.singleValueContainer()++ switch self {++ case .string(let value): try container.encode(value)++ case .bool(let value): try container.encode(value)++ case .int(let value): try container.encode(value)++ case .double(let value): try container.encode(value)++ case .array(let value): try container.encode(value)++ case .object(let value): try container.encode(value)++ case .null: try container.encodeNil()++ }++ }++++ /// The value as an object, when it is one.++ var objectValue: [String: KitValue]? {++ if case .object(let value) = self { return value }++ return nil++ }++++ /// The value as an array, when it is one.++ var arrayValue: [KitValue]? {++ if case .array(let value) = self { return value }++ return nil++ }++}++++/// Reading the props of a component.++extension Dictionary where Key == String, Value == KitValue {++ /// A string prop, or nothing when the document does not carry it.++ func string(_ key: String) -> String? {++ if case .string(let value)? = self[key] { return value }++ return nil++ }++++ /// A boolean prop.++ func bool(_ key: String) -> Bool? {++ if case .bool(let value)? = self[key] { return value }++ return nil++ }++++ /// A number prop.++ func int(_ key: String) -> Int? {++ switch self[key] {++ case .int(let value)?: return value++ case .double(let value)?: return Int(value)++ default: return nil++ }++ }++++ /// An array prop.++ func array(_ key: String) -> [KitValue]? {++ if case .array(let value)? = self[key] { return value }++ return nil++ }++++ /// An object prop.++ func object(_ key: String) -> [String: KitValue]? {++ if case .object(let value)? = self[key] { return value }++ return nil++ }++}++++// ── The components ──++++/// What a component carries: its props and its children.++public protocol KitComponent {++ var props: [String: KitValue] { get }++ var children: [KitNode] { get }++}++++/// A link the kit draws: where it goes and what it says.++public struct KitLink {++ public let href: String++ public let label: String++++ public init(href: String, label: String) {++ self.href = href++ self.label = label++ }++}++++/// One tab: its key (what the web puts in the URL), its label and its count.++public struct KitTab {++ public let key: String++ public let label: String++ public let count: Int?++++ public init(key: String, label: String, count: Int? = nil) {++ self.key = key++ self.label = label++ self.count = count++ }++}++++/// One number with its label.++public struct KitStat {++ public let value: String++ public let label: String++ public let href: String?++++ public init(value: String, label: String, href: String? = nil) {++ self.value = value++ self.label = label++ self.href = href++ }++}++++/// A button: one action, a link or a run.++public struct KitButton: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var label: String { props.string("label") ?? "" }++ public var href: String? { props.string("href") }++ public var iconName: String? { props.string("icon") }++++ /// The button's kind (OvKind comes from the kit's preamble).++ var kind: OvKind {++ switch props.string("kind") {++ case "quiet": return .quiet++ case "danger": return .danger++ default: return .primary++ }++ }++}++++/// A card: one thing, the whole card is one link.++public struct KitCard: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var href: String { props.string("href") ?? "" }++ public var title: String { props.string("title") ?? "" }++ public var sub: String? { props.string("sub") }++ public var who: String? { props.string("who") }++ public var shownAt: String? { props.string("when") }++ public var imageURL: URL? {++ guard let raw = props.string("image") else { return nil }++ return URL(string: raw)++ }++++ /// The badge: the one the document gives, or LIVE.++ public var badgeText: String? {++ if let badge = props.string("badge") { return badge }++ if props.bool("live") == true { return "LIVE" }++ return nil++ }++++ /// The media's shape, as the view takes it.++ public var ratioValue: CGFloat {++ guard let raw = props.string("ratio") else { return 16.0 / 9.0 }++ let parts = raw.split(separator: ":")++ if parts.count == 2, let a = Double(parts[0]), let b = Double(parts[1]), b != 0 {++ return CGFloat(a / b)++ }++ return 16.0 / 9.0++ }++++ /// The count: a number, or [number, word].++ public var countText: String? {++ switch props["count"] {++ case .int(let value)?: return String(value)++ case .double(let value)?: return String(Int(value))++ case .array(let value)?:++ guard let first = value.first else { return nil }++ var number = ""++ switch first {++ case .int(let n): number = String(n)++ case .double(let n): number = String(Int(n))++ default: return nil++ }++ if value.count > 1, case .string(let word) = value[1] { return number + " " + word }++ return number++ default: return nil++ }++ }++++ /// The line under the title: who, the count, when.++ public var metaText: String? {++ let parts = [who, countText, shownAt].compactMap { $0 }++ return parts.isEmpty ? nil : parts.joined(separator: " · ")++ }++}++++/// A rail: a row of cards that scrolls sideways.++public struct KitRail: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++++ /// The link to the whole list, if the document gives one.++ public var more: KitLink? {++ guard let link = props.object("more"), let href = link.string("href") else { return nil }++ return KitLink(href: href, label: link.string("label") ?? href)++ }++++ /// The cards the rail holds.++ public var cards: [KitCard] { children.compactMap { $0 as? KitCard } }++++ /// The card nodes the rail holds, ready to draw.++ public var cardNodes: [KitNode] {++ children.filter { node in++ if case .card = node { return true }++ return false++ }++ }++}++++/// A row: one item in a list.++public struct KitRow: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++ public var href: String? { props.string("href") }++ public var sub: String? { props.string("sub") }++ public var meta: String? { props.string("meta") }++ public var unread: Int? { props.int("unread") }++}++++/// A list: rows in one rounded panel.++public struct KitList: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String? { props.string("title") }++ public var empty: String? { props.string("empty") }++++ /// The rows the list holds.++ public var rows: [KitRow] { children.compactMap { $0 as? KitRow } }++++ /// The row nodes the list holds, ready to draw.++ public var rowNodes: [KitNode] {++ children.filter { node in++ if case .row = node { return true }++ return false++ }++ }++}++++/// A section: a titled group of anything.++public struct KitSection: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++ public var note: String? { props.string("note") }++}++++/// A fold: a group that opens and closes.++public struct KitFold: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++ public var meta: String? { props.string("meta") }++}++++/// Tabs: the sections of a screen, one at a time.++public struct KitTabs: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ /// The tabs, as the document gives them.++ public var tabs: [KitTab] {++ guard let items = props.array("items") else { return [] }++ var out: [KitTab] = []++ for item in items {++ guard let tab = item.objectValue, let key = tab.string("key") else { continue }++ out.append(KitTab(key: key, label: tab.string("label") ?? key, count: tab.int("count")))++ }++ return out++ }++++ /// The tab that is open: the one the document names, or the first.++ public var selectedIndex: Int {++ let active = props.string("active")++ if let active = active, let index = tabs.firstIndex(where: { $0.key == active }) { return index }++ return 0++ }++}++++/// Stats: the numbers, in a row.++public struct KitStats: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ /// The numbers, as the view takes them.++ public var items: [(value: String, label: String)] {++ guard let rows = props.array("items") else { return [] }++ var out: [(value: String, label: String)] = []++ for row in rows {++ guard let pair = row.arrayValue, pair.count >= 2 else { continue }++ var value = ""++ switch pair[0] {++ case .string(let text): value = text++ case .int(let number): value = String(number)++ case .double(let number): value = String(Int(number))++ default: continue++ }++ guard case .string(let label) = pair[1] else { continue }++ out.append((value: value, label: label))++ }++ return out++ }++}++++/// A notice: one line that says how things stand.++public struct KitNotice: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var text: String { props.string("text") ?? "" }++++ /// A warning or an error says itself louder.++ public var isError: Bool {++ let kind = props.string("kind")++ return kind == "error" || kind == "warn"++ }++}++++/// An empty state: what a screen shows when it has nothing to show.++public struct KitEmpty: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++ public var text: String? { props.string("text") }++}++++// ── The document ──++++/// A node of a kit document: one component, with its props and its children.++///++/// The cases are the kit's components; every one of them has a view in OvKit.swift that KitView draws with. A new++/// component in components.js needs a case here (and a view), or the kit check fails.++public enum KitNode: Codable {++ case button(KitButton)++ case card(KitCard)++ case rail(KitRail)++ case row(KitRow)++ case list(KitList)++ case section(KitSection)++ case fold(KitFold)++ case tabs(KitTabs)++ case stats(KitStats)++ case notice(KitNotice)++ case empty(KitEmpty)++++ public init(from decoder: Decoder) throws {++ let container = try decoder.container(keyedBy: KitCodingKey.self)++ let type = try container.decode(String.self, forKey: KitCodingKey("type"))++ let props = try container.decodeIfPresent([String: KitValue].self, forKey: KitCodingKey("props")) ?? [:]++ let children = try container.decodeIfPresent([KitNode].self, forKey: KitCodingKey("children")) ?? []++ switch type {++ case "button": self = .button(KitButton(props: props, children: children))++ case "card": self = .card(KitCard(props: props, children: children))++ case "rail": self = .rail(KitRail(props: props, children: children))++ case "row": self = .row(KitRow(props: props, children: children))++ case "list": self = .list(KitList(props: props, children: children))++ case "section": self = .section(KitSection(props: props, children: children))++ case "fold": self = .fold(KitFold(props: props, children: children))++ case "tabs": self = .tabs(KitTabs(props: props, children: children))++ case "stats": self = .stats(KitStats(props: props, children: children))++ case "notice": self = .notice(KitNotice(props: props, children: children))++ case "empty": self = .empty(KitEmpty(props: props, children: children))++ default:++ throw DecodingError.dataCorruptedError(++ forKey: KitCodingKey("type"),++ in: container,++ debugDescription: "unknown kit component: \(type)"++ )++ }++ }++++ public func encode(to encoder: Encoder) throws {++ var container = encoder.container(keyedBy: KitCodingKey.self)++ try container.encode(typeName, forKey: KitCodingKey("type"))++ try container.encode(props, forKey: KitCodingKey("props"))++ if !children.isEmpty {++ try container.encode(children, forKey: KitCodingKey("children"))++ }++ }++++ /// The component's type, as it is written in the document.++ public var typeName: String {++ switch self {++ case .button: return "button"++ case .card: return "card"++ case .rail: return "rail"++ case .row: return "row"++ case .list: return "list"++ case .section: return "section"++ case .fold: return "fold"++ case .tabs: return "tabs"++ case .stats: return "stats"++ case .notice: return "notice"++ case .empty: return "empty"++ }++ }++++ /// The component's props.++ public var props: [String: KitValue] {++ switch self {++ case .button(let node): return node.props++ case .card(let node): return node.props++ case .rail(let node): return node.props++ case .row(let node): return node.props++ case .list(let node): return node.props++ case .section(let node): return node.props++ case .fold(let node): return node.props++ case .tabs(let node): return node.props++ case .stats(let node): return node.props++ case .notice(let node): return node.props++ case .empty(let node): return node.props++ }++ }++++ /// The component's children.++ public var children: [KitNode] {++ switch self {++ case .button(let node): return node.children++ case .card(let node): return node.children++ case .rail(let node): return node.children++ case .row(let node): return node.children++ case .list(let node): return node.children++ case .section(let node): return node.children++ case .fold(let node): return node.children++ case .tabs(let node): return node.children++ case .stats(let node): return node.children++ case .notice(let node): return node.children++ case .empty(let node): return node.children++ }++ }++}++++/// The key of a kit document: any name a node carries.++struct KitCodingKey: CodingKey {++ var stringValue: String++ var intValue: Int? { nil }++++ init(_ name: String) { self.stringValue = name }++ init?(stringValue: String) { self.stringValue = stringValue }++ init?(intValue: Int) { return nil }++}++++/// What a tap asks the app to do: open a link, or run the action a button names.++public enum KitAction {++ /// Follow a link (a card, a row, a button with an href).++ case open(String)++++ /// Run the action a button names (the app decides what that means).++ case run(String)++}++++// ── Drawing it ──++++/// A kit document, drawn.++///++/// The app wears the theme's tokens around it (OvTokens, OvScale; see the kit's preamble), and this draws every++/// component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button++/// does.++public struct KitView: View {++ public let doc: KitNode++ public var onAction: (KitAction) -> Void++++ public init(doc: KitNode, onAction: @escaping (KitAction) -> Void = { _ in }) {++ self.doc = doc++ self.onAction = onAction++ }++++ public var body: some View {++ VStack(alignment: .leading, spacing: OvScale.spaceMd) {++ KitNodeBody(node: doc, onAction: onAction)++ }++ }++}++++/// One node, drawn with the kit's view for its component.++///++/// A struct and not a function: a view that draws its own children has to name a type, and a type can name itself.++private struct KitNodeBody: View {++ let node: KitNode++ let onAction: (KitAction) -> Void++++ var body: some View {++ switch node {++ case .button(let n): buttonView(n)++ case .card(let n): cardView(n)++ case .rail(let n): railView(n)++ case .row(let n): rowView(n)++ case .list(let n): listView(n)++ case .section(let n): sectionView(n)++ case .fold(let n): foldView(n)++ case .tabs(let n): tabsView(n)++ case .stats(let n): statsView(n)++ case .notice(let n): noticeView(n)++ case .empty(let n): emptyView(n)++ }++ }++++ /// A button.++ @ViewBuilder++ private func buttonView(_ node: KitButton) -> some View {++ OvButton(label: node.label, kind: node.kind, icon: node.iconName) {++ if let href = node.href {++ self.act(.open(href))++ } else {++ self.act(.run(node.label))++ }++ }++ }++++ /// A card: the whole card is one link.++ @ViewBuilder++ private func cardView(_ node: KitCard) -> some View {++ Button { self.act(.open(node.href)) } label: {++ OvCard(++ title: node.title,++ image: node.imageURL,++ ratio: node.ratioValue,++ badge: node.badgeText,++ sub: node.sub,++ meta: node.metaText++ )++ }++ .buttonStyle(.plain)++ }++++ /// A rail of cards.++ @ViewBuilder++ private func railView(_ node: KitRail) -> some View {++ OvRail(title: node.title, seeAll: node.more.map { self.moreAction($0) }) {++ self.railCards(node)++ }++ }++++ /// The cards of a rail.++ private func railCards(_ node: KitRail) -> some View {++ ForEach(node.cardNodes.indices, id: \.self) { index in++ KitNodeBody(node: node.cardNodes[index], onAction: self.onAction)++ }++ }++++ /// A row.++ @ViewBuilder++ private func rowView(_ node: KitRow) -> some View {++ Button {++ if let href = node.href { self.act(.open(href)) }++ } label: {++ OvRow(title: node.title, sub: node.sub, meta: node.meta)++ }++ .buttonStyle(.plain)++ }++++ /// A list of rows.++ @ViewBuilder++ private func listView(_ node: KitList) -> some View {++ OvList(title: node.title) {++ self.listRows(node)++ }++ }++++ /// The rows of a list, or the empty state when there are none.++ private func listRows(_ node: KitList) -> some View {++ Group {++ if node.rowNodes.isEmpty {++ if let empty = node.empty {++ OvEmpty(title: empty)++ } else {++ EmptyView()++ }++ } else {++ ForEach(node.rowNodes.indices, id: \.self) { index in++ KitNodeBody(node: node.rowNodes[index], onAction: self.onAction)++ }++ }++ }++ }++++ /// A section.++ @ViewBuilder++ private func sectionView(_ node: KitSection) -> some View {++ OvSection(title: node.title, note: node.note) {++ self.sectionChildren(node)++ }++ }++++ /// The children of a section.++ private func sectionChildren(_ node: KitSection) -> some View {++ ForEach(node.children.indices, id: \.self) { index in++ KitNodeBody(node: node.children[index], onAction: self.onAction)++ }++ }++++ /// A fold.++ @ViewBuilder++ private func foldView(_ node: KitFold) -> some View {++ OvFold(title: node.title, meta: node.meta) {++ self.foldChildren(node)++ }++ }++++ /// The children of a fold.++ private func foldChildren(_ node: KitFold) -> some View {++ ForEach(node.children.indices, id: \.self) { index in++ KitNodeBody(node: node.children[index], onAction: self.onAction)++ }++ }++++ /// The tabs of a screen.++ @ViewBuilder++ private func tabsView(_ node: KitTabs) -> some View {++ KitTabsHost(tabs: node.tabs.map { $0.label }, selected: node.selectedIndex)++ }++++ /// The numbers.++ @ViewBuilder++ private func statsView(_ node: KitStats) -> some View {++ OvStats(items: node.items)++ }++++ /// A notice.++ @ViewBuilder++ private func noticeView(_ node: KitNotice) -> some View {++ OvNotice(text: node.text, isError: node.isError)++ }++++ /// An empty state.++ @ViewBuilder++ private func emptyView(_ node: KitEmpty) -> some View {++ OvEmpty(title: node.title, text: node.text)++ }++++ /// What the rail's "See all" does.++ private func moreAction(_ link: KitLink) -> () -> Void {++ { self.act(.open(link.href)) }++ }++++ /// A tap, on its way to the app.++ private func act(_ action: KitAction) {++ self.onAction(action)++ }++}++++/// The tabs of a document: the open one is the app's business, so it lives here.++struct KitTabsHost: View {++ let tabs: [String]++ let selected: Int++ @State private var current: Int++++ init(tabs: [String], selected: Int) {++ self.tabs = tabs++ self.selected = selected++ _current = State(initialValue: selected)++ }++++ var body: some View {++ OvTabs(tabs: tabs, selected: $current)++ }++}nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++// scripts/native/: the kit renderers — a kit document, drawn natively on Android and iOS.++//++// The renderers are the kit's proof that a kit document is not a web page in a frame: the same document is drawn by++// kit.js on the web, by KitView on Android and by KitView on iOS. These checks keep them honest:++// - the sample document passes the kit's own validator, and the web draws it;++// - every component in SPECS has a native view that both renderers call, so a new spec without native support++// fails here instead of in an app.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const components = require('../components');++const kit = require('../kit');++const { sources } = require('../scripts/native-check');++++const root = path.join(__dirname, '..');++const kotlin = fs.readFileSync(path.join(root, 'scripts/native/compose/KitRenderer.kt'), 'utf8');++const swift = fs.readFileSync(path.join(root, 'scripts/native/swiftui/KitRenderer.swift'), 'utf8');++++// ── The sample document: a hero, a card rail, a list, stats and a notice ──++const sample = JSON.parse(fs.readFileSync(path.join(root, 'scripts/native/sample-doc.json'), 'utf8'));++const checked = components.validate(sample);++assert.ok(checked.ok, `the sample document is valid: ${checked.errors.join('; ')}`);++++const types = new Set();++(function walk(node) {++ if (!node || typeof node !== 'object') return;++ if (typeof node.type === 'string') types.add(node.type);++ for (const child of node.children || []) walk(child);++})(sample);++for (const type of ['section', 'button', 'rail', 'card', 'list', 'row', 'stats', 'notice']) {++ assert.ok(types.has(type), `the sample document has a ${type}`);++}++++// The same document, drawn by the web.++const html = components.render(sample, kit);++assert.ok(html.includes('OpenVibe'), 'the web draws the sample document');++assert.ok(html.length > 200, 'the web draws something');++++// ── The renderers: every component in SPECS is drawn, in both languages ──++for (const spec of components.SPECS) {++ const view = `Ov${spec.id[0].toUpperCase()}${spec.id.slice(1)}`;++ assert.ok(kotlin.includes(view), `${spec.id}: the Compose renderer draws it (${view})`);++ assert.ok(kotlin.includes(`"${spec.id}"`), `${spec.id}: the Compose renderer knows the type`);++ assert.ok(swift.includes(view), `${spec.id}: the SwiftUI renderer draws it (${view})`);++ assert.ok(swift.includes(`"${spec.id}"`), `${spec.id}: the SwiftUI renderer knows the type`);++}++++// The renderers wear the generated tokens, not hard-coded colours.++assert.match(kotlin, /MaterialTheme\.colorScheme/, 'the Compose renderer wears the theme\'s colour scheme');++assert.match(kotlin, /OvScale/, 'the Compose renderer wears the theme\'s scales');++assert.match(swift, /OvScale/, 'the SwiftUI renderer wears the theme\'s scales');++++// Both renderers read a kit document, and both take a tap back to the app.++assert.match(kotlin, /fun KitView\(/, 'the Compose renderer has a KitView');++assert.match(kotlin, /sealed class KitNode/, 'the Compose renderer has a sealed KitNode');++assert.match(swift, /struct KitView: View/, 'the SwiftUI renderer has a KitView');++assert.match(swift, /enum KitNode: Codable/, 'the SwiftUI renderer decodes a KitNode');++assert.match(swift, /JSONDecoder|Codable/, 'the SwiftUI renderer decodes with Codable');++++// ── The native check compiles both renderers with the tokens and the specs ──++const byPath = new Map(sources().files.map((f) => [f.path, f.text]));++assert.ok(byPath.has('kotlin/KitRenderer.kt'), 'the Compose renderer is written for the compiler');++assert.ok(byPath.has('swift/KitRenderer.swift'), 'the SwiftUI renderer is written for the compiler');++assert.strictEqual(byPath.get('kotlin/KitRenderer.kt'), kotlin, 'the Compose renderer is the checked-in one');++assert.strictEqual(byPath.get('swift/KitRenderer.swift'), swift, 'the SwiftUI renderer is the checked-in one');++++console.log(`✓ the sample document is valid and the web draws it: ${[...types].join(', ')}`);++console.log(`✓ the renderers draw every component: ${components.SPECS.map((s) => s.id).join(', ')}`);++console.log('native-kit-renderer: all checks passed');
@@ -11,6 +11,7 @@ on:
@@ -11,6 +11,7 @@ on:
- 'components.js'
- 'components.js'
- 'builtin-themes.js'
- 'builtin-themes.js'
- 'scripts/native-check.js'
- 'scripts/native-check.js'
+ - 'scripts/native/**'
- 'native/kotlin/**'
- 'native/kotlin/**'
- '.github/workflows/native.yml'
- '.github/workflows/native.yml'
push:
push:
@@ -20,6 +21,7 @@ on:
@@ -20,6 +21,7 @@ on:
- 'components.js'
- 'components.js'
- 'builtin-themes.js'
- 'builtin-themes.js'
- 'scripts/native-check.js'
- 'scripts/native-check.js'
+ - 'scripts/native/**'
- 'native/kotlin/**'
- 'native/kotlin/**'
- '.github/workflows/native.yml'
- '.github/workflows/native.yml'
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.47.0 — 2026-10-29
+
+- **The kit renders natively, on Android and iOS.** `scripts/native/compose/KitRenderer.kt` (`KitView(doc: KitNode)`, a
+ Composable that parses the document with org.json) and `scripts/native/swiftui/KitRenderer.swift`
+ (`KitView(doc: KitNode)`, a SwiftUI `View` that decodes it with Codable) draw a kit document with the kit's own
+ reference views, wearing the generated tokens (the Material 3 colour scheme and `OvScale` in Compose, `OvTokens` and
+ `OvScale` in SwiftUI). A tap comes back as an action, so the app decides what a link or a button does.
+- A sample document (`scripts/native/sample-doc.json`: a hero, a card rail, a list, stats and a notice) passes
+ `components.validate()`, and the native CI compiles the Kotlin against Compose and type-checks the Swift against
+ SwiftUI together with the tokens and the reference views. A component added to `components.js` without a native
+ view now fails the kit check, so the two languages cannot fall behind the web.
+
## 4.46.1 — 2026-10-29
## 4.46.1 — 2026-10-29
- `showcase.apps`: a set of six or nine is three a row (`sc-apps--c3`), on a phone and from 600 px. Four columns left one
- `showcase.apps`: a set of six or nine is three a row (`sc-apps--c3`), on a phone and from 600 px. Four columns left one
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.46.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.47.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.46.1",
+ "version": "4.47.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -63,3 +63,50 @@ Status: the reference views follow the Compose Material 3 and SwiftUI APIs, and
@@ -63,3 +63,50 @@ Status: the reference views follow the Compose Material 3 and SwiftUI APIs, and
`scripts/native-check.js` writes the preamble and every view out as Kotlin and Swift, and the native CI job
`scripts/native-check.js` writes the preamble and every view out as Kotlin and Swift, and the native CI job
(`.github/workflows/native.yml`) compiles the Kotlin with kotlinc against the Compose runtime and UI artifacts and
(`.github/workflows/native.yml`) compiles the Kotlin with kotlinc against the Compose runtime and UI artifacts and
type-checks the Swift with swiftc against SwiftUI. A view that does not compile fails the build.
type-checks the Swift with swiftc against SwiftUI. A view that does not compile fails the build.
+## Native renderers
+
+A kit document is not a web page in a frame: it is data, and every client draws it with its own views. Next to the
+reference views, the kit ships two renderers that do exactly that:
+
+| File | What it is |
+| --- | --- |
+| `scripts/native/compose/KitRenderer.kt` | `KitView(doc: KitNode)`, a Composable that draws a kit document with the kit's Compose views |
+| `scripts/native/swiftui/KitRenderer.swift` | `KitView(doc: KitNode)`, a SwiftUI `View` that draws the same document with the kit's SwiftUI views |
+| `scripts/native/sample-doc.json` | A sample document: a hero, a card rail, a list, stats and a notice |
+
+An app embeds the renderer in one line:
+
+```kotlin
+// Android
+val doc = KitNode.parse(json) // org.json, as Android carries it
+MaterialTheme(colorScheme = VibeDarkColorScheme) {
+ KitView(doc) { action -> // a tap comes back as an action
+ when (action) {
+ is KitAction.Open -> open(action.href)
+ is KitAction.Run -> run(action.id)
+ }
+ }
+}
+```
+
+```swift
+// iOS
+let doc = try JSONDecoder().decode(KitNode.self, from: data)
+KitView(doc: doc) { action in // a tap comes back as an action
+ switch action {
+ case .open(let href): open(href)
+ case .run(let id): run(id)
+ }
+}
+```
+
+Both renderers wear the generated tokens — the Material 3 colour scheme and `OvScale` in Compose, `OvTokens` and
+`OvScale` in SwiftUI — and both draw every component the validator accepts, by calling that component's reference
+view. A component added to `components.js` without a native view fails the kit check, so the two languages never
+fall behind the web.
+
+A kit document from Design (`/api/v1/components/validate`) renders the same on the web, on Android and on iOS: the
+web through `components.render(doc, kit)`, the apps through `KitView(doc)`. The native CI
+(`.github/workflows/native.yml`) compiles the Kotlin against Compose and type-checks the Swift against SwiftUI,
+together with the tokens and the reference views, so a renderer that no longer compiles fails the build here and not
+in an app.
@@ -61,6 +61,9 @@ dependencies {
@@ -61,6 +61,9 @@ dependencies {
composeClasspath("androidx.compose.material3:material3:$material3Version")
composeClasspath("androidx.compose.material3:material3:$material3Version")
composeClasspath("androidx.compose.material:material-icons-extended:$composeVersion")
composeClasspath("androidx.compose.material:material-icons-extended:$composeVersion")
composeClasspath("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
composeClasspath("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
+ // The kit renderer's JSON reader. Android carries org.json in the platform; the check compiles against the
+ // same class from Maven Central, so the renderer is checked against the parser it ships with.
+ composeClasspath("org.json:json:20240303")
composeCompilerPlugin("org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$kotlinVersion")
composeCompilerPlugin("org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$kotlinVersion")
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.46.1",
+ "version": "4.47.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.46.1",
+ "version": "4.47.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.46.1",
+ "version": "4.47.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -14,8 +14,10 @@
@@ -14,8 +14,10 @@
* and the kit's scales (OvScale)
* and the kit's scales (OvScale)
* <out>/kotlin/OvKit.kt the kit's Compose reference views: components.js' preamble and every spec
* <out>/kotlin/OvKit.kt the kit's Compose reference views: components.js' preamble and every spec
* <out>/kotlin/OvKitStubs.kt the check's stand-ins: the image loader an app brings (Coil, Glide)
* <out>/kotlin/OvKitStubs.kt the check's stand-ins: the image loader an app brings (Coil, Glide)
+ * <out>/kotlin/KitRenderer.kt the kit renderer: a kit document, drawn with those views
* <out>/swift/VibeTokens.swift the theme's SwiftUI output: both halves (the dark and the light tokens)
* <out>/swift/VibeTokens.swift the theme's SwiftUI output: both halves (the dark and the light tokens)
* <out>/swift/OvKit.swift the kit's SwiftUI reference views
* <out>/swift/OvKit.swift the kit's SwiftUI reference views
+ * <out>/swift/KitRenderer.swift the kit renderer: a kit document, drawn with those views
* <out>/manifest.json what was written, and where it came from
* <out>/manifest.json what was written, and where it came from
*
*
* The native CI job (.github/workflows/native.yml) compiles the Kotlin with kotlinc against the Compose runtime and
* The native CI job (.github/workflows/native.yml) compiles the Kotlin with kotlinc against the Compose runtime and
@@ -160,6 +162,18 @@ function swiftKit() {
@@ -160,6 +162,18 @@ function swiftKit() {
return `${parts.join('\n')}\n`;
return `${parts.join('\n')}\n`;
}
}
+/**
+ * The renderers: the kit's own sources, checked in under scripts/native/, copied into the folder the compiler
+ * reads so they are compiled (Kotlin) and type-checked (Swift) with the tokens and the reference views.
+ */
+function renderers() {
+ const dir = path.join(__dirname, 'native');
+ return [
+ { path: 'kotlin/KitRenderer.kt', text: fs.readFileSync(path.join(dir, 'compose', 'KitRenderer.kt'), 'utf8') },
+ { path: 'swift/KitRenderer.swift', text: fs.readFileSync(path.join(dir, 'swiftui', 'KitRenderer.swift'), 'utf8') },
+ ];
+}
+
/**
/**
* The native sources, as { path, text }: the theme's Kotlin and Swift, and the kit's.
* The native sources, as { path, text }: the theme's Kotlin and Swift, and the kit's.
* The theme is compiled as a pair (its dark half and its light half), so both colour schemes land in one file —
* The theme is compiled as a pair (its dark half and its light half), so both colour schemes land in one file —
@@ -178,6 +192,7 @@ function sources() {
@@ -178,6 +192,7 @@ function sources() {
{ path: 'kotlin/OvKitStubs.kt', text: COMPOSE_STUBS },
{ path: 'kotlin/OvKitStubs.kt', text: COMPOSE_STUBS },
{ path: `swift/${name}Tokens.swift`, text: tokens.compile(docs, 'swiftui') },
{ path: `swift/${name}Tokens.swift`, text: tokens.compile(docs, 'swiftui') },
{ path: 'swift/OvKit.swift', text: swiftKit() },
{ path: 'swift/OvKit.swift', text: swiftKit() },
+ ...renderers(),
],
],
};
};
}
}
@@ -219,4 +234,4 @@ function main(argv) {
@@ -219,4 +234,4 @@ function main(argv) {
if (require.main === module) process.exit(main(process.argv.slice(2)));
if (require.main === module) process.exit(main(process.argv.slice(2)));
-module.exports = { sources, write, themes, main };
+module.exports = { sources, write, themes, renderers, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,580 @@
@@ -0,0 +1,580 @@
+// The OpenVibe kit for Jetpack Compose: a kit document, rendered.
+//
+// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with
+// kit.js; this file draws the same document with the kit's own Compose views (OvKit.kt), wearing the theme's tokens
+// (the Material 3 colour scheme and OvScale).
+//
+// val doc = KitNode.parse(json)
+// MaterialTheme(colorScheme = VibeDarkColorScheme) {
+// KitView(doc) { action -> open(action) }
+// }
+//
+// The JSON is read with org.json, the parser Android carries in the platform.
+package network.openvibe.kit
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import network.openvibe.theme.OvScale
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** What a tap asks the app to do: open a link, or run the action a button names. */
+sealed class KitAction {
+ /** Follow a link (a card, a row, a button with an href). */
+ data class Open(val href: String) : KitAction()
+
+ /** Run the action a button names (the app decides what that means). */
+ data class Run(val id: String) : KitAction()
+}
+
+/** A link the kit draws: where it goes and what it says. */
+data class KitLink(val href: String, val label: String)
+
+/** One tab: its key (what the web puts in the URL), its label and its count. */
+data class KitTab(val key: String, val label: String, val count: Int? = null)
+
+/** One number with its label. */
+data class KitStat(val value: String, val label: String, val href: String? = null)
+
+/**
+ * A node of a kit document: one component, with its props and its children.
+ *
+ * The sealed cases are the kit's components; every one of them has a reference view in OvKit.kt that this file
+ * draws with. A new component in components.js needs a case here (and a view), or the kit check fails.
+ */
+sealed class KitNode {
+ /** The component's type, as it is written in the document. */
+ abstract val type: String
+
+ /** The component's children, in document order. */
+ abstract val children: List<KitNode>
+
+ /** A button: one action, a link or a run. */
+ data class Button(
+ val label: String,
+ val href: String? = null,
+ val icon: String? = null,
+ val kind: OvKind = OvKind.Primary,
+ val small: Boolean = false,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "button"
+ }
+
+ /** A card: one thing, the whole card is one link. */
+ data class Card(
+ val href: String,
+ val title: String,
+ val variant: String? = null,
+ val ratio: Float? = null,
+ val image: String? = null,
+ val alt: String? = null,
+ val badge: String? = null,
+ val live: Boolean = false,
+ val corner: String? = null,
+ val sub: String? = null,
+ val preview: String? = null,
+ val who: String? = null,
+ val avatar: String? = null,
+ val count: String? = null,
+ val shownAt: String? = null,
+ val level: Int? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "card"
+
+ /** The media's shape, as the view takes it. */
+ val ratioValue: Float get() = ratio ?: 16f / 9f
+
+ /** The line under the title: who, the count, when. */
+ val metaText: String?
+ get() = listOfNotNull(who, count, shownAt).joinToString(" · ").ifEmpty { null }
+
+ /** The badge: the one the document gives, or LIVE. */
+ val badgeText: String? get() = badge ?: if (live) "LIVE" else null
+ }
+
+ /** A rail: a row of cards that scrolls sideways. */
+ data class Rail(
+ val title: String,
+ val id: String? = null,
+ val more: KitLink? = null,
+ val size: String = "m",
+ val wrap: Boolean = false,
+ val label: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "rail"
+
+ /** The cards the rail holds. */
+ val cards: List<Card> get() = children.filterIsInstance<Card>()
+ }
+
+ /** A row: one item in a list. */
+ data class Row(
+ val title: String,
+ val href: String? = null,
+ val rel: String? = null,
+ val meta: String? = null,
+ val sub: String? = null,
+ val mark: String? = null,
+ val unread: Int? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "row"
+ }
+
+ /** A list: rows in one rounded panel. */
+ data class ListPanel(
+ val title: String? = null,
+ val more: KitLink? = null,
+ val empty: String? = null,
+ val flush: Boolean = false,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "list"
+
+ /** The rows the list holds. */
+ val rows: List<Row> get() = children.filterIsInstance<Row>()
+ }
+
+ /** A section: a titled group of anything. */
+ data class Section(
+ val title: String,
+ val id: String? = null,
+ val count: Int? = null,
+ val note: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "section"
+ }
+
+ /** A fold: a group that opens and closes. */
+ data class Fold(
+ val title: String,
+ val id: String? = null,
+ val meta: String? = null,
+ val open: Boolean = false,
+ val tone: String? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "fold"
+ }
+
+ /** Tabs: the sections of a screen, one at a time. */
+ data class Tabs(
+ val items: List<KitTab> = emptyList(),
+ val base: String? = null,
+ val active: String? = null,
+ val param: String = "tab",
+ val label: String = "Sections",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "tabs"
+
+ /** The tab that is open: the one the document names, or the first. */
+ val selectedIndex: Int
+ get() {
+ val i = items.indexOfFirst { it.key == active }
+ return if (i >= 0) i else 0
+ }
+ }
+
+ /** Stats: the numbers, in a row. */
+ data class Stats(
+ val items: List<KitStat> = emptyList(),
+ val label: String = "In numbers",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "stats"
+ }
+
+ /** A notice: one line that says how things stand. */
+ data class Notice(
+ val text: String,
+ val kind: OvNoticeKind = OvNoticeKind.Info,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "notice"
+ }
+
+ /** An empty state: what a screen shows when it has nothing to show. */
+ data class Empty(
+ val title: String,
+ val text: String? = null,
+ val icon: String? = null,
+ val action: KitLink? = null,
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "empty"
+ }
+
+ companion object {
+ /** A document, as JSON, into its nodes. Throws when a node is not a component the kit knows. */
+ fun parse(json: String): KitNode = parse(JSONObject(json))
+
+ /** A node, as JSON, into its component. Throws when the type is not one the kit knows. */
+ fun parse(obj: JSONObject): KitNode {
+ val type = obj.optString("type", "")
+ val props = obj.optJSONObject("props") ?: JSONObject()
+ val kids = childrenOf(obj.optJSONArray("children"))
+ return when (type) {
+ "button" -> Button(
+ label = props.str("label") ?: "",
+ href = props.str("href"),
+ icon = props.str("icon"),
+ kind = when (props.str("kind")) {
+ "quiet" -> OvKind.Quiet
+ "danger" -> OvKind.Danger
+ else -> OvKind.Primary
+ },
+ small = props.bool("small"),
+ )
+ "card" -> Card(
+ href = props.str("href") ?: "",
+ title = props.str("title") ?: "",
+ variant = props.str("type"),
+ ratio = ratioOf(props.str("ratio")),
+ image = props.str("image"),
+ alt = props.str("alt"),
+ badge = props.str("badge"),
+ live = props.bool("live"),
+ corner = props.str("corner"),
+ sub = props.str("sub"),
+ preview = props.str("preview"),
+ who = props.str("who"),
+ avatar = props.str("avatar"),
+ count = props.count("count"),
+ shownAt = props.str("when"),
+ level = props.int("level"),
+ )
+ "rail" -> Rail(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ more = props.link("more"),
+ size = props.str("size") ?: "m",
+ wrap = props.bool("wrap"),
+ label = props.str("label"),
+ children = kids,
+ )
+ "row" -> Row(
+ title = props.str("title") ?: "",
+ href = props.str("href"),
+ rel = props.str("rel"),
+ meta = props.str("meta"),
+ sub = props.str("sub"),
+ mark = props.str("mark"),
+ unread = props.int("unread"),
+ )
+ "list" -> ListPanel(
+ title = props.str("title"),
+ more = props.link("more"),
+ empty = props.str("empty"),
+ flush = props.bool("flush"),
+ children = kids,
+ )
+ "section" -> Section(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ count = props.int("count"),
+ note = props.str("note"),
+ children = kids,
+ )
+ "fold" -> Fold(
+ title = props.str("title") ?: "",
+ id = props.str("id"),
+ meta = props.str("meta"),
+ open = props.bool("open"),
+ tone = props.str("tone"),
+ children = kids,
+ )
+ "tabs" -> Tabs(
+ items = tabsOf(props.array("items")),
+ base = props.str("base"),
+ active = props.str("active"),
+ param = props.str("param") ?: "tab",
+ label = props.str("label") ?: "Sections",
+ )
+ "stats" -> Stats(
+ items = statsOf(props.array("items")),
+ label = props.str("label") ?: "In numbers",
+ )
+ "notice" -> Notice(
+ text = props.str("text") ?: "",
+ kind = when (props.str("kind")) {
+ "ok" -> OvNoticeKind.Ok
+ "warn" -> OvNoticeKind.Warn
+ "error" -> OvNoticeKind.Error
+ else -> OvNoticeKind.Info
+ },
+ )
+ "empty" -> Empty(
+ title = props.str("title") ?: "",
+ text = props.str("text"),
+ icon = props.str("icon"),
+ action = props.link("action"),
+ )
+ else -> throw IllegalArgumentException("unknown kit component: " + type)
+ }
+ }
+
+ /** The children of a node, parsed. */
+ private fun childrenOf(array: JSONArray?): List<KitNode> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitNode>()
+ for (i in 0 until array.length()) {
+ val child = array.optJSONObject(i) ?: continue
+ out.add(parse(child))
+ }
+ return out
+ }
+
+ /** The tabs of a tab row. */
+ private fun tabsOf(array: JSONArray?): List<KitTab> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitTab>()
+ for (i in 0 until array.length()) {
+ val tab = array.optJSONObject(i) ?: continue
+ val key = tab.str("key") ?: continue
+ out.add(KitTab(key, tab.str("label") ?: key, tab.int("count")))
+ }
+ return out
+ }
+
+ /** The numbers of a stats row. */
+ private fun statsOf(array: JSONArray?): List<KitStat> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitStat>()
+ for (i in 0 until array.length()) {
+ val row = array.optJSONArray(i) ?: continue
+ val value = row.optString(0, "")
+ val label = row.optString(1, "")
+ val href = if (row.length() > 2 && !row.isNull(2)) row.optString(2, "") else null
+ out.add(KitStat(value, label, href))
+ }
+ return out
+ }
+ }
+}
+
+// ── Reading the props ──
+
+/** A string prop, or nothing when the document does not carry it. */
+private fun JSONObject.str(key: String): String? = if (has(key) && !isNull(key)) optString(key, "") else null
+
+/** A boolean prop. */
+private fun JSONObject.bool(key: String, def: Boolean = false): Boolean =
+ if (has(key) && !isNull(key)) optBoolean(key, def) else def
+
+/** A number prop. */
+private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
+
+/** An array prop. */
+private fun JSONObject.array(key: String): JSONArray? = if (has(key) && !isNull(key)) optJSONArray(key) else null
+
+/** A link prop: { href, label }. */
+private fun JSONObject.link(key: String): KitLink? {
+ val obj = optJSONObject(key) ?: return null
+ val href = obj.str("href") ?: return null
+ return KitLink(href, obj.str("label") ?: href)
+}
+
+/** A count prop: a number, or [number, word]. */
+private fun JSONObject.count(key: String): String? {
+ val value = opt(key) ?: return null
+ return when (value) {
+ is Number -> value.toString()
+ is String -> value
+ is JSONArray -> {
+ val number = value.opt(0)
+ val word = value.optString(1, "")
+ if (number == null) null else if (word.isEmpty()) number.toString() else number.toString() + " " + word
+ }
+ else -> null
+ }
+}
+
+/** A ratio prop: "16:9" and the like, as the number the view takes. */
+private fun ratioOf(raw: String?): Float? {
+ if (raw == null) return null
+ val parts = raw.split(":")
+ if (parts.size == 2) {
+ val a = parts[0].trim().toFloatOrNull()
+ val b = parts[1].trim().toFloatOrNull()
+ if (a != null && b != null && b != 0f) return a / b
+ }
+ return raw.trim().toFloatOrNull()
+}
+
+// ── Drawing it ──
+
+/**
+ * A kit document, drawn.
+ *
+ * The app wears the theme's Material 3 colour scheme around it (see the kit's preamble), and this draws every
+ * component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button
+ * does.
+ */
+@Composable
+fun KitView(
+ doc: KitNode,
+ icons: Map<String, ImageVector> = emptyMap(),
+ onAction: (KitAction) -> Unit = {},
+) {
+ Column(Modifier.fillMaxWidth()) {
+ KitNodeView(doc, icons, onAction)
+ }
+}
+
+/** One node, drawn with the kit's view for its component. */
+@Composable
+fun KitNodeView(
+ node: KitNode,
+ icons: Map<String, ImageVector> = emptyMap(),
+ onAction: (KitAction) -> Unit = {},
+) {
+ when (node) {
+ is KitNode.Button -> OvButton(
+ label = node.label,
+ onClick = {
+ val href = node.href
+ if (href != null) onAction(KitAction.Open(href)) else onAction(KitAction.Run(node.label))
+ },
+ kind = node.kind,
+ icon = node.icon?.let { icons[it] },
+ )
+ is KitNode.Card -> OvCard(
+ title = node.title,
+ onOpen = { onAction(KitAction.Open(node.href)) },
+ image = node.image,
+ ratio = node.ratioValue,
+ badge = node.badgeText,
+ sub = node.sub,
+ meta = node.metaText,
+ )
+ is KitNode.Rail -> OvRail(
+ title = node.title,
+ seeAll = node.more?.let { more ->
+ val href = more.href
+ { onAction(KitAction.Open(href)) }
+ },
+ ) {
+ items(node.cards) { card ->
+ KitNodeView(card, icons, onAction)
+ }
+ }
+ is KitNode.Row -> {
+ val href = node.href
+ val open: (() -> Unit)? = if (href != null) {
+ val link = href
+ { onAction(KitAction.Open(link)) }
+ } else {
+ null
+ }
+ val markName = node.mark
+ val mark: (@Composable () -> Unit)? = if (markName != null) {
+ val name = markName
+ val slot: @Composable () -> Unit = { OvInitials(name) }
+ slot
+ } else {
+ null
+ }
+ val unread = node.unread
+ val trail: (@Composable () -> Unit)? = if (unread != null && unread > 0) {
+ val count = unread
+ val slot: @Composable () -> Unit = {
+ Text(
+ count.toString(),
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ fontSize = OvScale.TypeSizeMeta,
+ )
+ }
+ slot
+ } else {
+ null
+ }
+ OvRow(
+ title = node.title,
+ onOpen = open,
+ sub = node.sub,
+ meta = node.meta,
+ mark = mark,
+ trail = trail,
+ )
+ }
+ is KitNode.ListPanel -> OvList(title = node.title) {
+ val rows = node.rows
+ if (rows.isEmpty()) {
+ val empty = node.empty
+ if (empty != null) OvEmpty(empty)
+ } else {
+ rows.forEach { row ->
+ KitNodeView(row, icons, onAction)
+ }
+ }
+ }
+ is KitNode.Section -> OvSection(title = node.title, note = node.note) {
+ node.children.forEach { child ->
+ KitNodeView(child, icons, onAction)
+ }
+ }
+ is KitNode.Fold -> OvFold(title = node.title, meta = node.meta, initiallyOpen = node.open) {
+ node.children.forEach { child ->
+ KitNodeView(child, icons, onAction)
+ }
+ }
+ is KitNode.Tabs -> {
+ val labels = node.items.map { it.label }
+ var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }
+ OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })
+ }
+ is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
+ is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
+ is KitNode.Empty -> {
+ val link = node.action
+ val action: Pair<String, () -> Unit>? = if (link != null) {
+ val href = link.href
+ val label = link.label
+ label to { onAction(KitAction.Open(href)) }
+ } else {
+ null
+ }
+ OvEmpty(title = node.title, text = node.text, action = action)
+ }
+ }
+}
+
+/**
+ * A row's mark: up to two letters on the theme's accent, when the document gives a name and no picture.
+ */
+@Composable
+private fun OvInitials(name: String) {
+ val letters = name.trim().take(2).uppercase()
+ Surface(
+ shape = RoundedCornerShape(OvScale.RadiusPill),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Text(
+ letters,
+ Modifier.padding(horizontal = OvScale.SpaceSm, vertical = OvScale.SpaceXs),
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontSize = OvScale.TypeSizeCaption,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+{
+ "type": "section",
+ "props": {
+ "id": "hero",
+ "title": "OpenVibe",
+ "count": 5,
+ "note": "What is happening right now"
+ },
+ "children": [
+ {
+ "type": "row",
+ "props": {
+ "title": "Night walk",
+ "sub": "goosely walks the city, live",
+ "meta": "2 min",
+ "mark": "goosely"
+ }
+ },
+ {
+ "type": "button",
+ "props": {
+ "label": "Open the app",
+ "href": "https://openvibe.net/app",
+ "kind": "primary"
+ }
+ },
+ {
+ "type": "rail",
+ "props": {
+ "id": "now",
+ "title": "Live",
+ "more": {
+ "href": "https://openvibe.net/live",
+ "label": "See all"
+ },
+ "size": "m"
+ },
+ "children": [
+ {
+ "type": "card",
+ "props": {
+ "href": "https://openvibe.net/c/1",
+ "title": "Night walk",
+ "type": "stream",
+ "ratio": "16:9",
+ "badge": "LIVE",
+ "live": true,
+ "sub": "A city, at night",
+ "who": "goosely",
+ "count": [
+ 128,
+ "watching"
+ ],
+ "when": "2 min"
+ }
+ },
+ {
+ "type": "card",
+ "props": {
+ "href": "https://openvibe.net/c/2",
+ "title": "Studio session",
+ "type": "stream",
+ "ratio": "16:9",
+ "image": "https://openvibe.net/media/session.jpg",
+ "sub": "Three songs, one take",
+ "who": "mira",
+ "count": 42,
+ "when": "9 min"
+ }
+ },
+ {
+ "type": "card",
+ "props": {
+ "href": "https://openvibe.net/c/3",
+ "title": "Meme of the day",
+ "type": "listing",
+ "ratio": "1:1",
+ "sub": "It does not matter",
+ "who": "kit",
+ "count": [
+ 7,
+ "comments"
+ ],
+ "when": "1 h"
+ }
+ }
+ ]
+ },
+ {
+ "type": "list",
+ "props": {
+ "title": "Recently",
+ "more": {
+ "href": "https://openvibe.net/recent",
+ "label": "All"
+ },
+ "empty": "Nothing yet"
+ },
+ "children": [
+ {
+ "type": "row",
+ "props": {
+ "href": "https://openvibe.net/r/1",
+ "title": "The nightly show",
+ "sub": "Started 20 minutes ago",
+ "meta": "live"
+ }
+ },
+ {
+ "type": "row",
+ "props": {
+ "href": "https://openvibe.net/r/2",
+ "title": "Ask Vibe anything",
+ "sub": "A thread about the kit",
+ "meta": "3 new"
+ }
+ },
+ {
+ "type": "row",
+ "props": {
+ "title": "A quiet row",
+ "sub": "With nothing to open",
+ "meta": "yesterday"
+ }
+ }
+ ]
+ },
+ {
+ "type": "stats",
+ "props": {
+ "label": "In numbers",
+ "items": [
+ [
+ "12",
+ "live now"
+ ],
+ [
+ "1.4k",
+ "watching"
+ ],
+ [
+ "98",
+ "rooms open"
+ ]
+ ]
+ }
+ },
+ {
+ "type": "notice",
+ "props": {
+ "kind": "info",
+ "text": "The kit renders the same on the web, on Android and on iOS."
+ }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,761 @@
@@ -0,0 +1,761 @@
+// The OpenVibe kit for SwiftUI: a kit document, rendered.
+//
+// A kit document is a JSON tree of { type, props, children } (openvibe-shared/components). The web draws it with
+// kit.js; this file draws the same document with the kit's own SwiftUI views (OvKit.swift), wearing the theme's
+// tokens (OvTokens, OvScale).
+//
+// let doc = try JSONDecoder().decode(KitNode.self, from: data)
+// KitView(doc: doc) { action in open(action) }
+//
+// The document is decoded with Codable, so an app can fetch it, cache it and decode it off the main thread.
+import SwiftUI
+import Foundation
+
+// ── The document's values ──
+
+/// A JSON value, as a kit document carries them.
+public enum KitValue: Codable {
+ case string(String)
+ case bool(Bool)
+ case int(Int)
+ case double(Double)
+ case array([KitValue])
+ case object([String: KitValue])
+ case null
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode(Int.self) {
+ self = .int(value)
+ } else if let value = try? container.decode(Double.self) {
+ self = .double(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode([KitValue].self) {
+ self = .array(value)
+ } else {
+ self = .object(try container.decode([String: KitValue].self))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value): try container.encode(value)
+ case .bool(let value): try container.encode(value)
+ case .int(let value): try container.encode(value)
+ case .double(let value): try container.encode(value)
+ case .array(let value): try container.encode(value)
+ case .object(let value): try container.encode(value)
+ case .null: try container.encodeNil()
+ }
+ }
+
+ /// The value as an object, when it is one.
+ var objectValue: [String: KitValue]? {
+ if case .object(let value) = self { return value }
+ return nil
+ }
+
+ /// The value as an array, when it is one.
+ var arrayValue: [KitValue]? {
+ if case .array(let value) = self { return value }
+ return nil
+ }
+}
+
+/// Reading the props of a component.
+extension Dictionary where Key == String, Value == KitValue {
+ /// A string prop, or nothing when the document does not carry it.
+ func string(_ key: String) -> String? {
+ if case .string(let value)? = self[key] { return value }
+ return nil
+ }
+
+ /// A boolean prop.
+ func bool(_ key: String) -> Bool? {
+ if case .bool(let value)? = self[key] { return value }
+ return nil
+ }
+
+ /// A number prop.
+ func int(_ key: String) -> Int? {
+ switch self[key] {
+ case .int(let value)?: return value
+ case .double(let value)?: return Int(value)
+ default: return nil
+ }
+ }
+
+ /// An array prop.
+ func array(_ key: String) -> [KitValue]? {
+ if case .array(let value)? = self[key] { return value }
+ return nil
+ }
+
+ /// An object prop.
+ func object(_ key: String) -> [String: KitValue]? {
+ if case .object(let value)? = self[key] { return value }
+ return nil
+ }
+}
+
+// ── The components ──
+
+/// What a component carries: its props and its children.
+public protocol KitComponent {
+ var props: [String: KitValue] { get }
+ var children: [KitNode] { get }
+}
+
+/// A link the kit draws: where it goes and what it says.
+public struct KitLink {
+ public let href: String
+ public let label: String
+
+ public init(href: String, label: String) {
+ self.href = href
+ self.label = label
+ }
+}
+
+/// One tab: its key (what the web puts in the URL), its label and its count.
+public struct KitTab {
+ public let key: String
+ public let label: String
+ public let count: Int?
+
+ public init(key: String, label: String, count: Int? = nil) {
+ self.key = key
+ self.label = label
+ self.count = count
+ }
+}
+
+/// One number with its label.
+public struct KitStat {
+ public let value: String
+ public let label: String
+ public let href: String?
+
+ public init(value: String, label: String, href: String? = nil) {
+ self.value = value
+ self.label = label
+ self.href = href
+ }
+}
+
+/// A button: one action, a link or a run.
+public struct KitButton: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var label: String { props.string("label") ?? "" }
+ public var href: String? { props.string("href") }
+ public var iconName: String? { props.string("icon") }
+
+ /// The button's kind (OvKind comes from the kit's preamble).
+ var kind: OvKind {
+ switch props.string("kind") {
+ case "quiet": return .quiet
+ case "danger": return .danger
+ default: return .primary
+ }
+ }
+}
+
+/// A card: one thing, the whole card is one link.
+public struct KitCard: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var href: String { props.string("href") ?? "" }
+ public var title: String { props.string("title") ?? "" }
+ public var sub: String? { props.string("sub") }
+ public var who: String? { props.string("who") }
+ public var shownAt: String? { props.string("when") }
+ public var imageURL: URL? {
+ guard let raw = props.string("image") else { return nil }
+ return URL(string: raw)
+ }
+
+ /// The badge: the one the document gives, or LIVE.
+ public var badgeText: String? {
+ if let badge = props.string("badge") { return badge }
+ if props.bool("live") == true { return "LIVE" }
+ return nil
+ }
+
+ /// The media's shape, as the view takes it.
+ public var ratioValue: CGFloat {
+ guard let raw = props.string("ratio") else { return 16.0 / 9.0 }
+ let parts = raw.split(separator: ":")
+ if parts.count == 2, let a = Double(parts[0]), let b = Double(parts[1]), b != 0 {
+ return CGFloat(a / b)
+ }
+ return 16.0 / 9.0
+ }
+
+ /// The count: a number, or [number, word].
+ public var countText: String? {
+ switch props["count"] {
+ case .int(let value)?: return String(value)
+ case .double(let value)?: return String(Int(value))
+ case .array(let value)?:
+ guard let first = value.first else { return nil }
+ var number = ""
+ switch first {
+ case .int(let n): number = String(n)
+ case .double(let n): number = String(Int(n))
+ default: return nil
+ }
+ if value.count > 1, case .string(let word) = value[1] { return number + " " + word }
+ return number
+ default: return nil
+ }
+ }
+
+ /// The line under the title: who, the count, when.
+ public var metaText: String? {
+ let parts = [who, countText, shownAt].compactMap { $0 }
+ return parts.isEmpty ? nil : parts.joined(separator: " · ")
+ }
+}
+
+/// A rail: a row of cards that scrolls sideways.
+public struct KitRail: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+
+ /// The link to the whole list, if the document gives one.
+ public var more: KitLink? {
+ guard let link = props.object("more"), let href = link.string("href") else { return nil }
+ return KitLink(href: href, label: link.string("label") ?? href)
+ }
+
+ /// The cards the rail holds.
+ public var cards: [KitCard] { children.compactMap { $0 as? KitCard } }
+
+ /// The card nodes the rail holds, ready to draw.
+ public var cardNodes: [KitNode] {
+ children.filter { node in
+ if case .card = node { return true }
+ return false
+ }
+ }
+}
+
+/// A row: one item in a list.
+public struct KitRow: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+ public var href: String? { props.string("href") }
+ public var sub: String? { props.string("sub") }
+ public var meta: String? { props.string("meta") }
+ public var unread: Int? { props.int("unread") }
+}
+
+/// A list: rows in one rounded panel.
+public struct KitList: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String? { props.string("title") }
+ public var empty: String? { props.string("empty") }
+
+ /// The rows the list holds.
+ public var rows: [KitRow] { children.compactMap { $0 as? KitRow } }
+
+ /// The row nodes the list holds, ready to draw.
+ public var rowNodes: [KitNode] {
+ children.filter { node in
+ if case .row = node { return true }
+ return false
+ }
+ }
+}
+
+/// A section: a titled group of anything.
+public struct KitSection: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+ public var note: String? { props.string("note") }
+}
+
+/// A fold: a group that opens and closes.
+public struct KitFold: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+ public var meta: String? { props.string("meta") }
+}
+
+/// Tabs: the sections of a screen, one at a time.
+public struct KitTabs: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ /// The tabs, as the document gives them.
+ public var tabs: [KitTab] {
+ guard let items = props.array("items") else { return [] }
+ var out: [KitTab] = []
+ for item in items {
+ guard let tab = item.objectValue, let key = tab.string("key") else { continue }
+ out.append(KitTab(key: key, label: tab.string("label") ?? key, count: tab.int("count")))
+ }
+ return out
+ }
+
+ /// The tab that is open: the one the document names, or the first.
+ public var selectedIndex: Int {
+ let active = props.string("active")
+ if let active = active, let index = tabs.firstIndex(where: { $0.key == active }) { return index }
+ return 0
+ }
+}
+
+/// Stats: the numbers, in a row.
+public struct KitStats: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ /// The numbers, as the view takes them.
+ public var items: [(value: String, label: String)] {
+ guard let rows = props.array("items") else { return [] }
+ var out: [(value: String, label: String)] = []
+ for row in rows {
+ guard let pair = row.arrayValue, pair.count >= 2 else { continue }
+ var value = ""
+ switch pair[0] {
+ case .string(let text): value = text
+ case .int(let number): value = String(number)
+ case .double(let number): value = String(Int(number))
+ default: continue
+ }
+ guard case .string(let label) = pair[1] else { continue }
+ out.append((value: value, label: label))
+ }
+ return out
+ }
+}
+
+/// A notice: one line that says how things stand.
+public struct KitNotice: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var text: String { props.string("text") ?? "" }
+
+ /// A warning or an error says itself louder.
+ public var isError: Bool {
+ let kind = props.string("kind")
+ return kind == "error" || kind == "warn"
+ }
+}
+
+/// An empty state: what a screen shows when it has nothing to show.
+public struct KitEmpty: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+ public var text: String? { props.string("text") }
+}
+
+// ── The document ──
+
+/// A node of a kit document: one component, with its props and its children.
+///
+/// The cases are the kit's components; every one of them has a view in OvKit.swift that KitView draws with. A new
+/// component in components.js needs a case here (and a view), or the kit check fails.
+public enum KitNode: Codable {
+ case button(KitButton)
+ case card(KitCard)
+ case rail(KitRail)
+ case row(KitRow)
+ case list(KitList)
+ case section(KitSection)
+ case fold(KitFold)
+ case tabs(KitTabs)
+ case stats(KitStats)
+ case notice(KitNotice)
+ case empty(KitEmpty)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: KitCodingKey.self)
+ let type = try container.decode(String.self, forKey: KitCodingKey("type"))
+ let props = try container.decodeIfPresent([String: KitValue].self, forKey: KitCodingKey("props")) ?? [:]
+ let children = try container.decodeIfPresent([KitNode].self, forKey: KitCodingKey("children")) ?? []
+ switch type {
+ case "button": self = .button(KitButton(props: props, children: children))
+ case "card": self = .card(KitCard(props: props, children: children))
+ case "rail": self = .rail(KitRail(props: props, children: children))
+ case "row": self = .row(KitRow(props: props, children: children))
+ case "list": self = .list(KitList(props: props, children: children))
+ case "section": self = .section(KitSection(props: props, children: children))
+ case "fold": self = .fold(KitFold(props: props, children: children))
+ case "tabs": self = .tabs(KitTabs(props: props, children: children))
+ case "stats": self = .stats(KitStats(props: props, children: children))
+ case "notice": self = .notice(KitNotice(props: props, children: children))
+ case "empty": self = .empty(KitEmpty(props: props, children: children))
+ default:
+ throw DecodingError.dataCorruptedError(
+ forKey: KitCodingKey("type"),
+ in: container,
+ debugDescription: "unknown kit component: \(type)"
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: KitCodingKey.self)
+ try container.encode(typeName, forKey: KitCodingKey("type"))
+ try container.encode(props, forKey: KitCodingKey("props"))
+ if !children.isEmpty {
+ try container.encode(children, forKey: KitCodingKey("children"))
+ }
+ }
+
+ /// The component's type, as it is written in the document.
+ public var typeName: String {
+ switch self {
+ case .button: return "button"
+ case .card: return "card"
+ case .rail: return "rail"
+ case .row: return "row"
+ case .list: return "list"
+ case .section: return "section"
+ case .fold: return "fold"
+ case .tabs: return "tabs"
+ case .stats: return "stats"
+ case .notice: return "notice"
+ case .empty: return "empty"
+ }
+ }
+
+ /// The component's props.
+ public var props: [String: KitValue] {
+ switch self {
+ case .button(let node): return node.props
+ case .card(let node): return node.props
+ case .rail(let node): return node.props
+ case .row(let node): return node.props
+ case .list(let node): return node.props
+ case .section(let node): return node.props
+ case .fold(let node): return node.props
+ case .tabs(let node): return node.props
+ case .stats(let node): return node.props
+ case .notice(let node): return node.props
+ case .empty(let node): return node.props
+ }
+ }
+
+ /// The component's children.
+ public var children: [KitNode] {
+ switch self {
+ case .button(let node): return node.children
+ case .card(let node): return node.children
+ case .rail(let node): return node.children
+ case .row(let node): return node.children
+ case .list(let node): return node.children
+ case .section(let node): return node.children
+ case .fold(let node): return node.children
+ case .tabs(let node): return node.children
+ case .stats(let node): return node.children
+ case .notice(let node): return node.children
+ case .empty(let node): return node.children
+ }
+ }
+}
+
+/// The key of a kit document: any name a node carries.
+struct KitCodingKey: CodingKey {
+ var stringValue: String
+ var intValue: Int? { nil }
+
+ init(_ name: String) { self.stringValue = name }
+ init?(stringValue: String) { self.stringValue = stringValue }
+ init?(intValue: Int) { return nil }
+}
+
+/// What a tap asks the app to do: open a link, or run the action a button names.
+public enum KitAction {
+ /// Follow a link (a card, a row, a button with an href).
+ case open(String)
+
+ /// Run the action a button names (the app decides what that means).
+ case run(String)
+}
+
+// ── Drawing it ──
+
+/// A kit document, drawn.
+///
+/// The app wears the theme's tokens around it (OvTokens, OvScale; see the kit's preamble), and this draws every
+/// component with the kit's own views. A tap comes back as a KitAction, so the app decides what a link or a button
+/// does.
+public struct KitView: View {
+ public let doc: KitNode
+ public var onAction: (KitAction) -> Void
+
+ public init(doc: KitNode, onAction: @escaping (KitAction) -> Void = { _ in }) {
+ self.doc = doc
+ self.onAction = onAction
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: OvScale.spaceMd) {
+ KitNodeBody(node: doc, onAction: onAction)
+ }
+ }
+}
+
+/// One node, drawn with the kit's view for its component.
+///
+/// A struct and not a function: a view that draws its own children has to name a type, and a type can name itself.
+private struct KitNodeBody: View {
+ let node: KitNode
+ let onAction: (KitAction) -> Void
+
+ var body: some View {
+ switch node {
+ case .button(let n): buttonView(n)
+ case .card(let n): cardView(n)
+ case .rail(let n): railView(n)
+ case .row(let n): rowView(n)
+ case .list(let n): listView(n)
+ case .section(let n): sectionView(n)
+ case .fold(let n): foldView(n)
+ case .tabs(let n): tabsView(n)
+ case .stats(let n): statsView(n)
+ case .notice(let n): noticeView(n)
+ case .empty(let n): emptyView(n)
+ }
+ }
+
+ /// A button.
+ @ViewBuilder
+ private func buttonView(_ node: KitButton) -> some View {
+ OvButton(label: node.label, kind: node.kind, icon: node.iconName) {
+ if let href = node.href {
+ self.act(.open(href))
+ } else {
+ self.act(.run(node.label))
+ }
+ }
+ }
+
+ /// A card: the whole card is one link.
+ @ViewBuilder
+ private func cardView(_ node: KitCard) -> some View {
+ Button { self.act(.open(node.href)) } label: {
+ OvCard(
+ title: node.title,
+ image: node.imageURL,
+ ratio: node.ratioValue,
+ badge: node.badgeText,
+ sub: node.sub,
+ meta: node.metaText
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ /// A rail of cards.
+ @ViewBuilder
+ private func railView(_ node: KitRail) -> some View {
+ OvRail(title: node.title, seeAll: node.more.map { self.moreAction($0) }) {
+ self.railCards(node)
+ }
+ }
+
+ /// The cards of a rail.
+ private func railCards(_ node: KitRail) -> some View {
+ ForEach(node.cardNodes.indices, id: \.self) { index in
+ KitNodeBody(node: node.cardNodes[index], onAction: self.onAction)
+ }
+ }
+
+ /// A row.
+ @ViewBuilder
+ private func rowView(_ node: KitRow) -> some View {
+ Button {
+ if let href = node.href { self.act(.open(href)) }
+ } label: {
+ OvRow(title: node.title, sub: node.sub, meta: node.meta)
+ }
+ .buttonStyle(.plain)
+ }
+
+ /// A list of rows.
+ @ViewBuilder
+ private func listView(_ node: KitList) -> some View {
+ OvList(title: node.title) {
+ self.listRows(node)
+ }
+ }
+
+ /// The rows of a list, or the empty state when there are none.
+ private func listRows(_ node: KitList) -> some View {
+ Group {
+ if node.rowNodes.isEmpty {
+ if let empty = node.empty {
+ OvEmpty(title: empty)
+ } else {
+ EmptyView()
+ }
+ } else {
+ ForEach(node.rowNodes.indices, id: \.self) { index in
+ KitNodeBody(node: node.rowNodes[index], onAction: self.onAction)
+ }
+ }
+ }
+ }
+
+ /// A section.
+ @ViewBuilder
+ private func sectionView(_ node: KitSection) -> some View {
+ OvSection(title: node.title, note: node.note) {
+ self.sectionChildren(node)
+ }
+ }
+
+ /// The children of a section.
+ private func sectionChildren(_ node: KitSection) -> some View {
+ ForEach(node.children.indices, id: \.self) { index in
+ KitNodeBody(node: node.children[index], onAction: self.onAction)
+ }
+ }
+
+ /// A fold.
+ @ViewBuilder
+ private func foldView(_ node: KitFold) -> some View {
+ OvFold(title: node.title, meta: node.meta) {
+ self.foldChildren(node)
+ }
+ }
+
+ /// The children of a fold.
+ private func foldChildren(_ node: KitFold) -> some View {
+ ForEach(node.children.indices, id: \.self) { index in
+ KitNodeBody(node: node.children[index], onAction: self.onAction)
+ }
+ }
+
+ /// The tabs of a screen.
+ @ViewBuilder
+ private func tabsView(_ node: KitTabs) -> some View {
+ KitTabsHost(tabs: node.tabs.map { $0.label }, selected: node.selectedIndex)
+ }
+
+ /// The numbers.
+ @ViewBuilder
+ private func statsView(_ node: KitStats) -> some View {
+ OvStats(items: node.items)
+ }
+
+ /// A notice.
+ @ViewBuilder
+ private func noticeView(_ node: KitNotice) -> some View {
+ OvNotice(text: node.text, isError: node.isError)
+ }
+
+ /// An empty state.
+ @ViewBuilder
+ private func emptyView(_ node: KitEmpty) -> some View {
+ OvEmpty(title: node.title, text: node.text)
+ }
+
+ /// What the rail's "See all" does.
+ private func moreAction(_ link: KitLink) -> () -> Void {
+ { self.act(.open(link.href)) }
+ }
+
+ /// A tap, on its way to the app.
+ private func act(_ action: KitAction) {
+ self.onAction(action)
+ }
+}
+
+/// The tabs of a document: the open one is the app's business, so it lives here.
+struct KitTabsHost: View {
+ let tabs: [String]
+ let selected: Int
+ @State private var current: Int
+
+ init(tabs: [String], selected: Int) {
+ self.tabs = tabs
+ self.selected = selected
+ _current = State(initialValue: selected)
+ }
+
+ var body: some View {
+ OvTabs(tabs: tabs, selected: $current)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+// scripts/native/: the kit renderers — a kit document, drawn natively on Android and iOS.
+//
+// The renderers are the kit's proof that a kit document is not a web page in a frame: the same document is drawn by
+// kit.js on the web, by KitView on Android and by KitView on iOS. These checks keep them honest:
+// - the sample document passes the kit's own validator, and the web draws it;
+// - every component in SPECS has a native view that both renderers call, so a new spec without native support
+// fails here instead of in an app.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const components = require('../components');
+const kit = require('../kit');
+const { sources } = require('../scripts/native-check');
+
+const root = path.join(__dirname, '..');
+const kotlin = fs.readFileSync(path.join(root, 'scripts/native/compose/KitRenderer.kt'), 'utf8');
+const swift = fs.readFileSync(path.join(root, 'scripts/native/swiftui/KitRenderer.swift'), 'utf8');
+
+// ── The sample document: a hero, a card rail, a list, stats and a notice ──
+const sample = JSON.parse(fs.readFileSync(path.join(root, 'scripts/native/sample-doc.json'), 'utf8'));
+const checked = components.validate(sample);
+assert.ok(checked.ok, `the sample document is valid: ${checked.errors.join('; ')}`);
+
+const types = new Set();
+(function walk(node) {
+ if (!node || typeof node !== 'object') return;
+ if (typeof node.type === 'string') types.add(node.type);
+ for (const child of node.children || []) walk(child);
+})(sample);
+for (const type of ['section', 'button', 'rail', 'card', 'list', 'row', 'stats', 'notice']) {
+ assert.ok(types.has(type), `the sample document has a ${type}`);
+}
+
+// The same document, drawn by the web.
+const html = components.render(sample, kit);
+assert.ok(html.includes('OpenVibe'), 'the web draws the sample document');
+assert.ok(html.length > 200, 'the web draws something');
+
+// ── The renderers: every component in SPECS is drawn, in both languages ──
+for (const spec of components.SPECS) {
+ const view = `Ov${spec.id[0].toUpperCase()}${spec.id.slice(1)}`;
+ assert.ok(kotlin.includes(view), `${spec.id}: the Compose renderer draws it (${view})`);
+ assert.ok(kotlin.includes(`"${spec.id}"`), `${spec.id}: the Compose renderer knows the type`);
+ assert.ok(swift.includes(view), `${spec.id}: the SwiftUI renderer draws it (${view})`);
+ assert.ok(swift.includes(`"${spec.id}"`), `${spec.id}: the SwiftUI renderer knows the type`);
+}
+
+// The renderers wear the generated tokens, not hard-coded colours.
+assert.match(kotlin, /MaterialTheme\.colorScheme/, 'the Compose renderer wears the theme\'s colour scheme');
+assert.match(kotlin, /OvScale/, 'the Compose renderer wears the theme\'s scales');
+assert.match(swift, /OvScale/, 'the SwiftUI renderer wears the theme\'s scales');
+
+// Both renderers read a kit document, and both take a tap back to the app.
+assert.match(kotlin, /fun KitView\(/, 'the Compose renderer has a KitView');
+assert.match(kotlin, /sealed class KitNode/, 'the Compose renderer has a sealed KitNode');
+assert.match(swift, /struct KitView: View/, 'the SwiftUI renderer has a KitView');
+assert.match(swift, /enum KitNode: Codable/, 'the SwiftUI renderer decodes a KitNode');
+assert.match(swift, /JSONDecoder|Codable/, 'the SwiftUI renderer decodes with Codable');
+
+// ── The native check compiles both renderers with the tokens and the specs ──
+const byPath = new Map(sources().files.map((f) => [f.path, f.text]));
+assert.ok(byPath.has('kotlin/KitRenderer.kt'), 'the Compose renderer is written for the compiler');
+assert.ok(byPath.has('swift/KitRenderer.swift'), 'the SwiftUI renderer is written for the compiler');
+assert.strictEqual(byPath.get('kotlin/KitRenderer.kt'), kotlin, 'the Compose renderer is the checked-in one');
+assert.strictEqual(byPath.get('swift/KitRenderer.swift'), swift, 'the SwiftUI renderer is the checked-in one');
+
+console.log(`✓ the sample document is valid and the web draws it: ${[...types].join(', ')}`);
+console.log(`✓ the renderers draw every component: ${components.SPECS.map((s) => s.id).join(', ')}`);
+console.log('native-kit-renderer: all checks passed');