openvibers/openvibe.shared Commits

4.45.1: the native code is compiled, not just read (#186)

OP OpenVibers committed 14 days ago · cfda8b60e9 · 14 files, +521 −19

* The native code is compiled, not just read: a native CI job for the Compose and SwiftUI outputs

tokens.js compiles a theme into Kotlin and Swift; components.js carries a reference view for each of the kit's
components in both languages. Until now those outputs were only read as text: a generator that wrote code nobody
could compile went unnoticed until an app tried to use it.

scripts/native-check.js writes the theme's Compose and SwiftUI output (the default theme and its light half) and
the kit's reference views (the preamble and every spec) out as .kt and .swift files, with the imports the
snippets need around them. The new workflow compiles the Kotlin with kotlinc against the Compose runtime and UI
artifacts (the tiny Gradle build resolves the classpath, the Compose compiler plugin and the compiler itself) and
type-checks the Swift with swiftc against SwiftUI.

The check found two things in the Swift reference views: the card's ratio was 16 / 9 (integer division, so the
picture area came out square) and the numbers list keyed its rows by a tuple's field, which Swift cannot do.

* 4.45.1: the native code is compiled, not just read

A patch release: a new CI job (the native workflow) and a new script (scripts/native-check.js), no change to any
exported module, plus the two fixes the check found in the Swift reference views.

* The native check's paths read the same on every machine

The manifest's paths are written with slashes now, so the test does not look for 'kotlin\OvKit.kt' on Windows.

Changes

ddiff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
nnew file mode 100644
iindex 0000000..d14701b
---- /dev/null
++++ b/.github/workflows/native.yml
@@@ -0,0 +1,86 @@
++name: native
++# The generated native code, compiled for real. tokens.js compiles a theme into Kotlin (Jetpack Compose) and Swift
++# (SwiftUI); components.js carries a reference view for each of the kit's components in both languages. Until now
++# those outputs were only read as text: a generator that wrote code nobody could compile went unnoticed until an app
++# tried to use it. This workflow compiles the Kotlin against the Compose runtime and UI artifacts and type-checks the
++# Swift against SwiftUI.
++on:
++ pull_request:
++ paths:
++ - 'tokens.js'
++ - 'components.js'
++ - 'builtin-themes.js'
++ - 'scripts/native-check.js'
++ - 'native/kotlin/**'
++ - '.github/workflows/native.yml'
++ push:
++ branches: [main]
++ paths:
++ - 'tokens.js'
++ - 'components.js'
++ - 'builtin-themes.js'
++ - 'scripts/native-check.js'
++ - 'native/kotlin/**'
++ - '.github/workflows/native.yml'
++
++permissions:
++ contents: read
++
++jobs:
++ # The Kotlin: the theme's Compose output and the kit's Compose reference views, compiled by kotlinc against the
++ # Compose artifacts (the runtime, the UI, Material 3 and the icons).
++ kotlin:
++ name: kotlin (Jetpack Compose)
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ - uses: actions/setup-java@v4
++ with:
++ distribution: temurin
++ java-version: 17
++ - uses: gradle/actions/setup-gradle@v4
++ with:
++ gradle-version: 8.10.2
++ - name: write the native sources
++ run: node scripts/native-check.js --out "$RUNNER_TEMP/native"
++ - name: the Compose artifacts, the Compose compiler plugin and the Kotlin compiler
++ env:
++ OV_NATIVE_TOOLCHAIN: ${{ runner.temp }}/toolchain
++ run: gradle -p native/kotlin --no-daemon --quiet writeToolchain --console=plain
++ - name: compile the Kotlin
++ run: |
++ set -euo pipefail
++ tool="$RUNNER_TEMP/toolchain"
++ # kotlinc: the Kotlin compiler's command line (org.jetbrains.kotlin.cli.jvm.K2JVMCompiler), with the Compose
++ # compiler plugin, over the Compose artifacts.
++ java -cp "$(cat "$tool/compiler.txt")" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
++ -no-stdlib -no-reflect \
++ -cp "$(cat "$tool/compose.txt")" \
++ -Xplugin="$(cat "$tool/plugin.txt")" \
++ -d "$RUNNER_TEMP/kotlin-out" \
++ $(find "$RUNNER_TEMP/native/kotlin" -name '*.kt' | sort)
++ - name: what was compiled
++ run: find "$RUNNER_TEMP/kotlin-out" -name '*.class' | wc -l
++
++ # The Swift: the theme's SwiftUI output and the kit's SwiftUI reference views, type-checked against SwiftUI.
++ swift:
++ name: swift (SwiftUI)
++ runs-on: macos-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ - name: write the native sources
++ run: node scripts/native-check.js --out "$RUNNER_TEMP/native"
++ - name: type-check the Swift
++ run: |
++ set -euo pipefail
++ # The SDK's own version as the deployment target, so the newest SwiftUI (ContentUnavailableView and the
++ # scroll targets) is available to the check.
++ sdk=$(xcrun --show-sdk-path)
++ target="$(uname -m)-apple-macos$(xcrun --show-sdk-version | cut -d. -f1).0"
++ swiftc -typecheck -sdk "$sdk" -target "$target" $(find "$RUNNER_TEMP/native/swift" -name '*.swift' | sort)
ddiff --git a/.gitignore b/.gitignore
iindex 2d98f97..93505fb 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -1,2 +1,4 @@
node_modules/
*.tgz
++.gradle/
++build/
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex df0ea93..76b37a3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.45.1 — 2026-10-29
++
++- **The generated native code is compiled now, not just read.** `scripts/native-check.js` writes the theme's Compose
++ and SwiftUI output (the default theme and the light half it pairs with) and the kit's reference views (the
++ preamble and all eleven components) out as `.kt` and `.swift` files, and a new workflow
++ (`.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 generator that writes code nobody can compile fails the build
++ here, instead of waiting to be found in an app.
++- **Two things that check found in the Swift reference views:** the card's ratio was `16 / 9` (integer division, so
++ the picture area came out square) and the numbers list keyed its rows by a tuple's field, which Swift cannot do.
++
## 4.45.0 — 2026-10-29
- **Comfort settings beside the theme.** Four display preferences join motion, text size, seasonal and scheme in
ddiff --git a/README.md b/README.md
iindex ec4458a..81fa468 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.45.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.45.1 (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.
@@@ -24,7 +24,7 @@ were reconciled first.
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
--| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
++| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found). The native code is compiled too: `scripts/native-check.js` writes the generated Compose and SwiftUI sources out, and `.github/workflows/native.yml` compiles the Kotlin against the Compose artifacts and type-checks the Swift against SwiftUI |
Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
ddiff --git a/STATUS.json b/STATUS.json
iindex 9a6bf88..b4fe1af 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.45.0",
++ "version": "4.45.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/components.js b/components.js
iindex 8703713..cf16be3 100644
---- a/components.js
++++ b/components.js
@@@ -84,7 +84,7 @@ fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary,
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
-- .tint(kind == .danger ? OvTokens.colorStatusBadBase : kind == .quiet ? .clear : OvTokens.colorAccentStrong)
++ .tint(kind == .danger ? OvTokens.colorStatusBadBase : kind == .quiet ? Color.clear : OvTokens.colorAccentStrong)
.foregroundStyle(kind == .quiet ? OvTokens.colorTextPrimary : OvTokens.colorAccentOnStrong)
}
}`,
@@@ -126,7 +126,7 @@ fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Floa
swiftui: `struct OvCard: View {
let title: String
var image: URL? = nil
-- var ratio: CGFloat = 16 / 9
++ var ratio: CGFloat = 16.0 / 9.0
var badge: String? = nil
var sub: String? = nil
var meta: String? = nil
@@@ -179,7 +179,7 @@ fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.(
swiftui: `struct OvRail<Content: View>: View {
let title: String
var seeAll: (() -> Void)? = nil
-- @ViewBuilder let content: () -> Content
++ @ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: OvScale.spaceMd) {
HStack {
@@@ -263,7 +263,7 @@ fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {
}`,
swiftui: `struct OvList<Content: View>: View {
var title: String? = nil
-- @ViewBuilder let content: () -> Content
++ @ViewBuilder var content: () -> Content
var body: some View {
Section { content() } header: { if let title { Text(title).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)) } }
}
@@@ -298,7 +298,7 @@ fun OvSection(title: String, note: String? = null, content: @Composable ColumnSc
swiftui: `struct OvSection<Content: View>: View {
let title: String
var note: String? = nil
-- @ViewBuilder let content: () -> Content
++ @ViewBuilder var content: () -> Content
var body: some View {
GroupBox {
VStack(alignment: .leading) { content() }
@@@ -342,7 +342,7 @@ fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false,
let title: String
var meta: String? = nil
@State var open = false
-- @ViewBuilder let content: () -> Content
++ @ViewBuilder var content: () -> Content
var body: some View {
DisclosureGroup(isExpanded: $open) { content().padding(.top, OvScale.spaceSm) } label: {
HStack { Text(title).fontWeight(.bold); if let meta { Text(meta).foregroundStyle(OvTokens.colorTextMuted) } }.frame(minHeight: OvScale.sizeTap)
@@@ -412,10 +412,10 @@ fun OvStats(items: List<Pair<String, String>>) {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: OvScale.spaceSm) {
-- ForEach(items, id: \\.label) { item in
++ ForEach(items.indices, id: \\.self) { i in
VStack(alignment: .leading) {
-- Text(item.value).font(.system(size: OvScale.typeSizeTitle, weight: .heavy)).monospacedDigit()
-- Text(item.label).font(.system(size: OvScale.typeSizeCaption)).foregroundStyle(OvTokens.colorTextMuted)
++ Text(items[i].value).font(.system(size: OvScale.typeSizeTitle, weight: .heavy)).monospacedDigit()
++ Text(items[i].label).font(.system(size: OvScale.typeSizeCaption)).foregroundStyle(OvTokens.colorTextMuted)
}
.padding(.horizontal, 14).padding(.vertical, 8).frame(minWidth: 104, alignment: .leading)
.background(OvTokens.colorBgRaised, in: .rect(cornerRadius: OvScale.radiusMd))
ddiff --git a/docs/components.md b/docs/components.md
iindex 4cfeb65..87c278b 100644
---- a/docs/components.md
++++ b/docs/components.md
@@@ -59,5 +59,7 @@ theme's tokens it wears (`typealias OvTokens = VibeDarkTokens`). The theme files
`openvibe.design/tokens` (OpenVibe Tokens' Compose and SwiftUI targets), and the scales are `OvScale` in both.
OpenVibe.Design's `/components` page shows every component's spec, its web rendering and its Android and iOS views.
--Status: the reference views follow the Compose Material 3 and SwiftUI APIs. They are checked by review and by the
--tests' names, not compiled: this host has no Kotlin or Swift toolchain, and a native CI job is the next step.
++Status: the reference views follow the Compose Material 3 and SwiftUI APIs, and they are **compiled** now:
++`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.
ddiff --git a/docs/tokens.md b/docs/tokens.md
iindex b89f96b..cc5c83a 100644
---- a/docs/tokens.md
++++ b/docs/tokens.md
@@@ -70,8 +70,10 @@ nothing is passed through unchecked:
values try to inject code.
Status: the generated Kotlin, Swift and Dart are checked against the Compose Material 3, SwiftUI and Flutter APIs by
--review and by the tests' expected lines. This host has no Kotlin, Swift or Dart toolchain to compile them; a native
--CI job is the next step.
++review and by the tests' expected lines. The Kotlin and the Swift are also **compiled** now: `scripts/native-check.js`
++writes the generated sources out, 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. The Dart
++still has no toolchain here.
## Comfort settings (4.45.0)
ddiff --git a/native/kotlin/build.gradle.kts b/native/kotlin/build.gradle.kts
nnew file mode 100644
iindex 0000000..29a6f56
---- /dev/null
++++ b/native/kotlin/build.gradle.kts
@@@ -0,0 +1,88 @@
++// The tiny build the native CI job (.github/workflows/native.yml) uses to compile the generated Kotlin.
++//
++// It resolves three things and writes them out: the Compose artifacts (the runtime, the UI, Material 3 and the
++// icons), the Compose compiler plugin and the Kotlin compiler itself. The job then runs the compiler over the
++// generated sources, so a generator that writes Kotlin nobody can compile fails the build here.
++//
++// The sources are not in this folder: scripts/native-check.js writes them into a temporary folder, and the job says
++// where they are.
++plugins {
++ base
++}
++
++repositories {
++ google()
++ mavenCentral()
++}
++
++// The Compose versions the check compiles against. The runtime, the UI and the icons move together; Material 3 has
++// its own line.
++val composeVersion = "1.7.6"
++val material3Version = "1.3.1"
++// The Kotlin compiler and its Compose plugin have to be the same version.
++val kotlinVersion = "2.0.21"
++
++// The Compose artifacts are multiplatform: these attributes ask for the JVM ones (the desktop and the stub
++// variants), not the common metadata.
++val composeClasspath by configurations.creating {
++ isCanBeConsumed = false
++ isCanBeResolved = true
++ attributes {
++ attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class, Usage.JAVA_API))
++ attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category::class, Category.LIBRARY))
++ attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class, LibraryElements.JAR))
++ attribute(Attribute.of("org.jetbrains.kotlin.platform.type", String::class.java), "jvm")
++ }
++}
++
++val composeCompilerPlugin by configurations.creating {
++ isCanBeConsumed = false
++ isCanBeResolved = true
++}
++
++// The compiler itself: kotlinc's command line, plus what it needs to run.
++val kotlinCompiler by configurations.creating {
++ isCanBeConsumed = false
++ isCanBeResolved = true
++ attributes {
++ attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage::class, Usage.JAVA_API))
++ attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category::class, Category.LIBRARY))
++ attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class, LibraryElements.JAR))
++ attribute(Attribute.of("org.jetbrains.kotlin.platform.type", String::class.java), "jvm")
++ }
++}
++
++dependencies {
++ composeClasspath("androidx.compose.runtime:runtime:$composeVersion")
++ composeClasspath("androidx.compose.runtime:runtime-saveable:$composeVersion")
++ composeClasspath("androidx.compose.ui:ui:$composeVersion")
++ composeClasspath("androidx.compose.ui:ui-text:$composeVersion")
++ composeClasspath("androidx.compose.foundation:foundation:$composeVersion")
++ composeClasspath("androidx.compose.material3:material3:$material3Version")
++ composeClasspath("androidx.compose.material:material-icons-extended:$composeVersion")
++ composeClasspath("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
++
++ composeCompilerPlugin("org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$kotlinVersion")
++
++ kotlinCompiler("org.jetbrains.kotlin:kotlin-compiler-embeddable:$kotlinVersion")
++ kotlinCompiler("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
++ kotlinCompiler("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
++ kotlinCompiler("org.jetbrains:annotations:23.0.0")
++ kotlinCompiler("org.jetbrains.intellij.deps:trove4j:1.0.20200330")
++}
++
++// Where the job reads the three classpaths from (OV_NATIVE_TOOLCHAIN).
++tasks.register("writeToolchain") {
++ val compose = composeClasspath
++ val plugin = composeCompilerPlugin
++ val compiler = kotlinCompiler
++ val folder = providers.environmentVariable("OV_NATIVE_TOOLCHAIN").orElse("build/toolchain")
++ doLast {
++ val out = File(folder.get())
++ out.mkdirs()
++ File(out, "compose.txt").writeText(compose.files.joinToString(":") { it.absolutePath })
++ File(out, "plugin.txt").writeText(plugin.files.joinToString(":") { it.absolutePath })
++ File(out, "compiler.txt").writeText(compiler.files.joinToString(":") { it.absolutePath })
++ logger.lifecycle("wrote the Compose classpath, the Compose compiler plugin and the Kotlin compiler to $out")
++ }
++}
ddiff --git a/native/kotlin/settings.gradle.kts b/native/kotlin/settings.gradle.kts
nnew file mode 100644
iindex 0000000..182eaff
---- /dev/null
++++ b/native/kotlin/settings.gradle.kts
@@@ -0,0 +1,4 @@
++// The tiny Gradle build the native CI job uses to put the Compose artifacts on a classpath.
++// It has no source of its own: scripts/native-check.js writes the generated Kotlin into a temporary folder and
++// OV_NATIVE_SRC says where.
++rootProject.name = "openvibe-native-kotlin"
ddiff --git a/package-lock.json b/package-lock.json
iindex 1d7db21..38cd00a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.45.0",
++ "version": "4.45.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.45.0",
++ "version": "4.45.1",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 24ac519..5c74795 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.45.0",
++ "version": "4.45.1",
"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
nnew file mode 100644
iindex 0000000..cc0c8ed
---- /dev/null
++++ b/scripts/native-check.js
@@@ -0,0 +1,222 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * scripts/native-check.js — the generated native code, written out so that a compiler can check it.
++ *
++ * tokens.js compiles a theme into Kotlin (Jetpack Compose) and Swift (SwiftUI); components.js carries a reference
++ * view for each of the kit's components, in both languages. Until now those outputs were only ever read as text.
++ * This script writes them out as real source files:
++ *
++ * node scripts/native-check.js # a temporary folder
++ * node scripts/native-check.js --out build/native # a folder of your choosing
++ *
++ * <out>/kotlin/VibeTheme.kt the theme's Compose output: the theme's tokens, its Material 3 colour schemes
++ * 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>/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>/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
++ * UI artifacts, and type-checks the Swift with swiftc against SwiftUI. A generator that writes code nobody can
++ * compile fails the build here instead of waiting to be found in an app.
++ *
++ * The kit's reference views are snippets: they carry no imports of their own, so this script writes the imports they
++ * need around them. Everything else is what the generators wrote, unchanged.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tokens = require('../tokens');
++const components = require('../components');
++const { BUILTIN_THEMES, DEFAULT_THEME, PAIRS } = require('../builtin-themes');
++
++// ── The themes the check compiles: the default theme and the light half it pairs with ──
++/** The default theme and its light counterpart, as the network's own theme objects. */
++function themes() {
++ const dark = DEFAULT_THEME || BUILTIN_THEMES[0];
++ const pair = PAIRS.find((p) => p[0] === dark.id) || PAIRS.find((p) => p[1] === dark.id) || [];
++ const lightId = pair.find((id) => id !== dark.id) || (BUILTIN_THEMES.find((t) => t.mode === 'light') || dark).id;
++ const light = BUILTIN_THEMES.find((t) => t.id === lightId) || dark;
++ return [dark, light];
++}
++
++// ── The Compose imports the kit's reference views need ──
++// The snippets are written to be read and copied, not to be compiled on their own, so they name their types without
++// saying where they come from. These are the imports every snippet in components.js needs.
++const COMPOSE_IMPORTS = [
++ 'androidx.compose.animation.AnimatedVisibility',
++ 'androidx.compose.foundation.BorderStroke',
++ 'androidx.compose.foundation.background',
++ 'androidx.compose.foundation.clickable',
++ 'androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior',
++ 'androidx.compose.foundation.layout.Arrangement',
++ 'androidx.compose.foundation.layout.Box',
++ 'androidx.compose.foundation.layout.Column',
++ 'androidx.compose.foundation.layout.ColumnScope',
++ 'androidx.compose.foundation.layout.PaddingValues',
++ 'androidx.compose.foundation.layout.Row',
++ 'androidx.compose.foundation.layout.RowScope',
++ 'androidx.compose.foundation.layout.Spacer',
++ 'androidx.compose.foundation.layout.aspectRatio',
++ 'androidx.compose.foundation.layout.fillMaxWidth',
++ 'androidx.compose.foundation.layout.height',
++ 'androidx.compose.foundation.layout.heightIn',
++ 'androidx.compose.foundation.layout.padding',
++ 'androidx.compose.foundation.layout.size',
++ 'androidx.compose.foundation.layout.width',
++ 'androidx.compose.foundation.layout.widthIn',
++ 'androidx.compose.foundation.lazy.LazyListScope',
++ 'androidx.compose.foundation.lazy.LazyRow',
++ 'androidx.compose.foundation.lazy.items',
++ 'androidx.compose.foundation.lazy.rememberLazyListState',
++ 'androidx.compose.foundation.shape.RoundedCornerShape',
++ 'androidx.compose.material.icons.Icons',
++ 'androidx.compose.material.icons.filled.Error',
++ 'androidx.compose.material.icons.filled.ExpandMore',
++ 'androidx.compose.material.icons.filled.Info',
++ 'androidx.compose.material3.Button',
++ 'androidx.compose.material3.ButtonDefaults',
++ 'androidx.compose.material3.Icon',
++ 'androidx.compose.material3.ListItem',
++ 'androidx.compose.material3.LocalTextStyle',
++ 'androidx.compose.material3.MaterialTheme',
++ 'androidx.compose.material3.OutlinedButton',
++ 'androidx.compose.material3.ScrollableTabRow',
++ 'androidx.compose.material3.Surface',
++ 'androidx.compose.material3.Tab',
++ 'androidx.compose.material3.Text',
++ 'androidx.compose.material3.TextButton',
++ 'androidx.compose.runtime.Composable',
++ 'androidx.compose.runtime.getValue',
++ 'androidx.compose.runtime.mutableStateOf',
++ 'androidx.compose.runtime.saveable.rememberSaveable',
++ 'androidx.compose.runtime.setValue',
++ 'androidx.compose.ui.Alignment',
++ 'androidx.compose.ui.Modifier',
++ 'androidx.compose.ui.draw.clip',
++ 'androidx.compose.ui.draw.rotate',
++ 'androidx.compose.ui.graphics.Color',
++ 'androidx.compose.ui.graphics.vector.ImageVector',
++ 'androidx.compose.ui.layout.ContentScale',
++ 'androidx.compose.ui.semantics.LiveRegionMode',
++ 'androidx.compose.ui.semantics.heading',
++ 'androidx.compose.ui.semantics.liveRegion',
++ 'androidx.compose.ui.semantics.semantics',
++ 'androidx.compose.ui.text.font.FontWeight',
++ 'androidx.compose.ui.text.style.TextAlign',
++ 'androidx.compose.ui.text.style.TextOverflow',
++ 'androidx.compose.ui.unit.dp',
++ 'androidx.compose.ui.unit.sp',
++ 'network.openvibe.theme.OvScale',
++];
++
++// The image loader an app brings (Coil, Glide) is not a Compose artifact, so the check stands in for it: the
++// reference view only needs the call to type-check, not to fetch a picture.
++const COMPOSE_STUBS = `// The check's stand-ins: what an app brings with it, not what the Compose artifacts carry.
++// The kit's card view draws a picture through an image loader (Coil, Glide); the CI does not pull one in, it only
++// needs the call to type-check.
++package network.openvibe.kit
++
++import androidx.compose.runtime.Composable
++import androidx.compose.ui.Modifier
++import androidx.compose.ui.layout.ContentScale
++
++@Composable
++fun AsyncImage(model: Any?, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) {
++}
++`;
++
++/** The Compose file with the kit's reference views: the preamble, then every spec's snippet. */
++function composeKit() {
++ const parts = [
++ '// The OpenVibe kit for Jetpack Compose: the reference views of components.js, with the imports they need.',
++ '// Generated by scripts/native-check.js — do not edit; fix components.js instead.',
++ 'package network.openvibe.kit',
++ '',
++ ...COMPOSE_IMPORTS.map((i) => `import ${i}`),
++ '',
++ components.PREAMBLE.compose,
++ '',
++ ];
++ for (const spec of components.SPECS) parts.push(`// ${spec.name} (${spec.id})`, spec.native.compose, '');
++ return `${parts.join('\n')}\n`;
++}
++
++/** The SwiftUI file with the kit's reference views: the preamble, then every spec's snippet. */
++function swiftKit() {
++ const parts = [
++ '// The OpenVibe kit for SwiftUI: the reference views of components.js, with the imports they need.',
++ '// Generated by scripts/native-check.js — do not edit; fix components.js instead.',
++ 'import SwiftUI',
++ 'import Foundation',
++ '',
++ components.PREAMBLE.swiftui,
++ '',
++ ];
++ for (const spec of components.SPECS) parts.push(`// ${spec.name} (${spec.id})`, spec.native.swiftui, '');
++ return `${parts.join('\n')}\n`;
++}
++
++/**
++ * 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 way an app carries them.
++ * The paths are written with slashes, so the manifest reads the same on every machine.
++ */
++function sources() {
++ const [dark, light] = themes();
++ const docs = [tokens.fromTheme(dark), tokens.fromTheme(light)];
++ const name = tokens.ident(dark.name || 'Theme');
++ return {
++ themes: { dark: dark.id, light: light.id, name },
++ files: [
++ { path: `kotlin/${name}Theme.kt`, text: tokens.compile(docs, 'compose') },
++ { path: 'kotlin/OvKit.kt', text: composeKit() },
++ { path: 'kotlin/OvKitStubs.kt', text: COMPOSE_STUBS },
++ { path: `swift/${name}Tokens.swift`, text: tokens.compile(docs, 'swiftui') },
++ { path: 'swift/OvKit.swift', text: swiftKit() },
++ ],
++ };
++}
++
++/** Write the sources into a folder. Returns { out, files } (the files written, with their sizes). */
++function write(out) {
++ const { themes: which, files } = sources();
++ const written = [];
++ for (const f of files) {
++ const full = path.join(out, ...f.path.split('/'));
++ fs.mkdirSync(path.dirname(full), { recursive: true });
++ fs.writeFileSync(full, f.text);
++ written.push({ path: f.path, bytes: Buffer.byteLength(f.text) });
++ }
++ const manifest = {
++ generator: 'openvibe-shared/scripts/native-check.js',
++ themes: which,
++ files: written,
++ };
++ fs.writeFileSync(path.join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
++ return { out, themes: which, files: written };
++}
++
++function main(argv) {
++ let out = path.join(os.tmpdir(), 'openvibe-native-check');
++ for (let i = 0; i < argv.length; i++) {
++ if (argv[i] === '--out') out = path.resolve(argv[++i] || out);
++ else if (argv[i].startsWith('--out=')) out = path.resolve(argv[i].slice(6));
++ else if (argv[i] === '--help' || argv[i] === '-h') {
++ console.log('usage: node scripts/native-check.js [--out <folder>]');
++ return 0;
++ }
++ }
++ const { out: dir, themes: which, files } = write(out);
++ console.log(`native-check: ${which.dark} (dark) and ${which.light} (light) → ${dir}`);
++ for (const f of files) console.log(` ${f.path} (${f.bytes} bytes)`);
++ return 0;
++}
++
++if (require.main === module) process.exit(main(process.argv.slice(2)));
++
++module.exports = { sources, write, themes, main };
ddiff --git a/test/native-check.test.js b/test/native-check.test.js
nnew file mode 100644
iindex 0000000..5ef4dc6
---- /dev/null
++++ b/test/native-check.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++// scripts/native-check.js: the generated Kotlin and Swift, written out so that a compiler can check them. This test
++// checks what the script writes (the native CI job compiles it: .github/workflows/native.yml).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const components = require('../components');
++const { sources, write, themes } = require('../scripts/native-check');
++
++// ── The themes: the default one and the light half it pairs with ──
++const [dark, light] = themes();
++assert.ok(dark && light, 'a theme and its light half');
++assert.strictEqual(dark.mode, 'dark');
++assert.strictEqual(light.mode, 'light');
++assert.notStrictEqual(dark.id, light.id, 'two different themes');
++
++// ── The files: the theme's Kotlin and Swift, and the kit's ──
++const { files } = sources();
++const byPath = new Map(files.map((f) => [f.path, f.text]));
++for (const p of ['kotlin/VibeTheme.kt', 'kotlin/OvKit.kt', 'kotlin/OvKitStubs.kt', 'swift/VibeTokens.swift', 'swift/OvKit.swift']) {
++ assert.ok(byPath.has(p), `${p} is written`);
++}
++for (const [p, text] of byPath) {
++ assert.ok(text.length > 200, `${p} has content`);
++ assert.ok(text.endsWith('\n'), `${p} ends with a newline`);
++ assert.ok(!text.includes('\r'), `${p} has no carriage returns`);
++}
++
++// The theme's Kotlin: both halves, the Material 3 colour schemes and the scales.
++const ktTheme = byPath.get('kotlin/VibeTheme.kt');
++assert.match(ktTheme, /^package network\.openvibe\.theme$/m);
++assert.match(ktTheme, /object VibeDarkTokens \{/);
++assert.match(ktTheme, /object VibeLightTokens \{/);
++assert.match(ktTheme, /val VibeDarkColorScheme: ColorScheme = darkColorScheme\(/);
++assert.match(ktTheme, /val VibeLightColorScheme: ColorScheme = lightColorScheme\(/);
++assert.match(ktTheme, /object OvScale \{/);
++
++// The theme's Swift: both halves and the scales.
++const swiftTheme = byPath.get('swift/VibeTokens.swift');
++assert.match(swiftTheme, /^import SwiftUI$/m);
++assert.match(swiftTheme, /public enum VibeDarkTokens \{/);
++assert.match(swiftTheme, /public enum VibeLightTokens \{/);
++assert.match(swiftTheme, /public enum OvScale \{/);
++
++// The kit's Kotlin: the preamble, every spec's view, and the imports they need.
++const ktKit = byPath.get('kotlin/OvKit.kt');
++assert.match(ktKit, /^package network\.openvibe\.kit$/m);
++assert.match(ktKit, /enum class OvKind \{ Primary, Quiet, Danger \}/);
++assert.match(ktKit, /enum class OvNoticeKind \{ Ok, Info, Warn, Error \}/);
++assert.match(ktKit, /import androidx\.compose\.material3\.MaterialTheme/);
++assert.match(ktKit, /import network\.openvibe\.theme\.OvScale/);
++for (const spec of components.SPECS) {
++ assert.ok(ktKit.includes(spec.native.compose), `${spec.id}: its Compose view is written`);
++ assert.ok(new RegExp(`fun Ov${spec.id[0].toUpperCase()}${spec.id.slice(1)}\\(`).test(ktKit), `${spec.id}: its Compose view is named`);
++}
++
++// The kit's Swift: the preamble (which theme the app wears) and every spec's view.
++const swiftKit = byPath.get('swift/OvKit.swift');
++assert.match(swiftKit, /^import SwiftUI$/m);
++assert.match(swiftKit, /typealias OvTokens = VibeDarkTokens/);
++assert.match(swiftKit, /enum OvKind \{ case primary, quiet, danger \}/);
++for (const spec of components.SPECS) {
++ assert.ok(swiftKit.includes(spec.native.swiftui), `${spec.id}: its SwiftUI view is written`);
++ assert.ok(swiftKit.includes(`struct Ov${spec.id[0].toUpperCase()}${spec.id.slice(1)}`), `${spec.id}: its SwiftUI view is named`);
++}
++
++// ── Writing them out ──
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openvibe-native-test-'));
++const out = write(dir);
++assert.strictEqual(out.out, dir);
++assert.ok(out.files.length === files.length);
++for (const f of out.files) {
++ const full = path.join(dir, f.path);
++ assert.ok(fs.existsSync(full), `${f.path} is on disk`);
++ assert.strictEqual(fs.readFileSync(full, 'utf8'), byPath.get(f.path), `${f.path} is what the generator wrote`);
++}
++const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
++assert.strictEqual(manifest.themes.dark, dark.id);
++assert.strictEqual(manifest.themes.light, light.id);
++assert.strictEqual(manifest.files.length, files.length);
++fs.rmSync(dir, { recursive: true, force: true });
++
++console.log(`✓ the native sources are written: ${files.map((f) => f.path).join(', ')}`);
++console.log('native-check: all checks passed');