openvibers/openvibe.shared Commits

4.47.0: the kit renders natively, on Android and iOS (#189)

OP OpenVibers committed 14 days ago · 923d6511ce · 13 files, +1652 −6

* 4.47.0: the kit renders natively, on Android and iOS

A kit document is data, and every client draws it with its own views. Two new
renderers do that now: 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). Both draw every component the validator accepts,
by calling that component's reference view, and wear the generated tokens.

A sample document (scripts/native/sample-doc.json: a hero, a card rail, a list,
stats and a notice) passes components.validate(). The native CI compiles the
Kotlin against Compose and type-checks the Swift against SwiftUI together with
the tokens and the reference views, and a test fails when a component has no
native view, so the two languages cannot fall behind the web.

* the native renderers, as the compilers want them

The Compose renderer was missing the reader for an array prop, and a local val
cannot be const. The SwiftUI renderer drew its children through a function that
returned some View, which made the opaque type depend on itself; a view that
draws its own children is a struct now, so the type can name itself.

* the SwiftUI renderer, as the type checker wants it

A pattern against a value that is not optional takes no question mark, and the rail's
cards and the list's rows are drawn from their nodes, not from the props they
carry.

Changes

ddiff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
iindex d14701b..1c229a6 100644
---- a/.github/workflows/native.yml
++++ b/.github/workflows/native.yml
@@@ -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'
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bc15cbe..ae7be03 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex bfd13c9..5676277 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 6c13c92..7737c02 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/components.md b/docs/components.md
iindex 87c278b..a10c4d9 100644
---- a/docs/components.md
++++ b/docs/components.md
@@@ -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 and
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.
ddiff --git a/native/kotlin/build.gradle.kts b/native/kotlin/build.gradle.kts
iindex 29a6f56..d7ac4e6 100644
---- a/native/kotlin/build.gradle.kts
++++ b/native/kotlin/build.gradle.kts
@@@ -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")
ddiff --git a/package-lock.json b/package-lock.json
iindex d5bbe2b..9485807 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 55eba4b..7c0d3e4 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/scripts/native-check.js b/scripts/native-check.js
iindex cc0c8ed..1f10bde 100644
---- a/scripts/native-check.js
++++ b/scripts/native-check.js
@@@ -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 };
ddiff --git a/scripts/native/compose/KitRenderer.kt b/scripts/native/compose/KitRenderer.kt
nnew file mode 100644
iindex 0000000..798dd7c
---- /dev/null
++++ b/scripts/native/compose/KitRenderer.kt
@@@ -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,
++ )
++ }
++}
ddiff --git a/scripts/native/sample-doc.json b/scripts/native/sample-doc.json
nnew file mode 100644
iindex 0000000..be050dd
---- /dev/null
++++ b/scripts/native/sample-doc.json
@@@ -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."
++ }
++ }
++ ]
++}
ddiff --git a/scripts/native/swiftui/KitRenderer.swift b/scripts/native/swiftui/KitRenderer.swift
nnew file mode 100644
iindex 0000000..fca2541
---- /dev/null
++++ b/scripts/native/swiftui/KitRenderer.swift
@@@ -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)
++ }
++}
ddiff --git a/test/native-kit-renderer.test.js b/test/native-kit-renderer.test.js
nnew file mode 100644
iindex 0000000..2c8adb6
---- /dev/null
++++ b/test/native-kit-renderer.test.js
@@@ -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');