openvibers/openvibe.shared Commits
4.45.1: the native code is compiled, not just read (#186)
Changes
nnew file mode 100644@@@ -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)@@@ -1,2 +1,4 @@node_modules/*.tgz++.gradle/++build/@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -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@@@ -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)",@@@ -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: Floaswiftui: `struct OvCard: View {let title: Stringvar image: URL? = nil-- var ratio: CGFloat = 16 / 9++ var ratio: CGFloat = 16.0 / 9.0var badge: String? = nilvar sub: String? = nilvar meta: String? = nil@@@ -179,7 +179,7 @@ fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.(swiftui: `struct OvRail<Content: View>: View {let title: Stringvar seeAll: (() -> Void)? = nil-- @ViewBuilder let content: () -> Content++ @ViewBuilder var content: () -> Contentvar 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: () -> Contentvar 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 ColumnScswiftui: `struct OvSection<Content: View>: View {let title: Stringvar note: String? = nil-- @ViewBuilder let content: () -> Content++ @ViewBuilder var content: () -> Contentvar body: some View {GroupBox {VStack(alignment: .leading) { content() }@@@ -342,7 +342,7 @@ fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false,let title: Stringvar meta: String? = nil@State var open = false-- @ViewBuilder let content: () -> Content++ @ViewBuilder var content: () -> Contentvar 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 inVStack(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))@@@ -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.@@@ -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)nnew file mode 100644@@@ -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")++ }++}nnew file mode 100644@@@ -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"@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -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)
@@ -1,2 +1,4 @@
@@ -1,2 +1,4 @@
node_modules/
node_modules/
*.tgz
*.tgz
+.gradle/
+build/
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.45.0 — 2026-10-29
- **Comfort settings beside the theme.** Four display preferences join motion, text size, seasonal and scheme in
- **Comfort settings beside the theme.** Four display preferences join motion, text size, seasonal and scheme in
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -24,7 +24,7 @@ were reconciled first.
@@ -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`) |
| 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) |
| 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 |
| 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')`,
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
`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.45.0",
+ "version": "4.45.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -84,7 +84,7 @@ fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary,
@@ -84,7 +84,7 @@ fun OvButton(label: String, onClick: () -> Unit, kind: OvKind = OvKind.Primary,
}
}
.buttonStyle(.borderedProminent)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.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)
.foregroundStyle(kind == .quiet ? OvTokens.colorTextPrimary : OvTokens.colorAccentOnStrong)
}
}
}`,
}`,
@@ -126,7 +126,7 @@ fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Floa
@@ -126,7 +126,7 @@ fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Floa
swiftui: `struct OvCard: View {
swiftui: `struct OvCard: View {
let title: String
let title: String
var image: URL? = nil
var image: URL? = nil
- var ratio: CGFloat = 16 / 9
+ var ratio: CGFloat = 16.0 / 9.0
var badge: String? = nil
var badge: String? = nil
var sub: String? = nil
var sub: String? = nil
var meta: String? = nil
var meta: String? = nil
@@ -179,7 +179,7 @@ fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.(
@@ -179,7 +179,7 @@ fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.(
swiftui: `struct OvRail<Content: View>: View {
swiftui: `struct OvRail<Content: View>: View {
let title: String
let title: String
var seeAll: (() -> Void)? = nil
var seeAll: (() -> Void)? = nil
- @ViewBuilder let content: () -> Content
+ @ViewBuilder var content: () -> Content
var body: some View {
var body: some View {
VStack(alignment: .leading, spacing: OvScale.spaceMd) {
VStack(alignment: .leading, spacing: OvScale.spaceMd) {
HStack {
HStack {
@@ -263,7 +263,7 @@ fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {
@@ -263,7 +263,7 @@ fun OvList(title: String? = null, content: @Composable ColumnScope.() -> Unit) {
}`,
}`,
swiftui: `struct OvList<Content: View>: View {
swiftui: `struct OvList<Content: View>: View {
var title: String? = nil
var title: String? = nil
- @ViewBuilder let content: () -> Content
+ @ViewBuilder var content: () -> Content
var body: some View {
var body: some View {
Section { content() } header: { if let title { Text(title).font(.system(size: OvScale.typeSizeCaption, weight: .heavy)) } }
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
@@ -298,7 +298,7 @@ fun OvSection(title: String, note: String? = null, content: @Composable ColumnSc
swiftui: `struct OvSection<Content: View>: View {
swiftui: `struct OvSection<Content: View>: View {
let title: String
let title: String
var note: String? = nil
var note: String? = nil
- @ViewBuilder let content: () -> Content
+ @ViewBuilder var content: () -> Content
var body: some View {
var body: some View {
GroupBox {
GroupBox {
VStack(alignment: .leading) { content() }
VStack(alignment: .leading) { content() }
@@ -342,7 +342,7 @@ fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false,
@@ -342,7 +342,7 @@ fun OvFold(title: String, meta: String? = null, initiallyOpen: Boolean = false,
let title: String
let title: String
var meta: String? = nil
var meta: String? = nil
@State var open = false
@State var open = false
- @ViewBuilder let content: () -> Content
+ @ViewBuilder var content: () -> Content
var body: some View {
var body: some View {
DisclosureGroup(isExpanded: $open) { content().padding(.top, OvScale.spaceSm) } label: {
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)
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>>) {
@@ -412,10 +412,10 @@ fun OvStats(items: List<Pair<String, String>>) {
var body: some View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: OvScale.spaceSm) {
HStack(spacing: OvScale.spaceSm) {
- ForEach(items, id: \\.label) { item in
+ ForEach(items.indices, id: \\.self) { i in
VStack(alignment: .leading) {
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)
.padding(.horizontal, 14).padding(.vertical, 8).frame(minWidth: 104, alignment: .leading)
.background(OvTokens.colorBgRaised, in: .rect(cornerRadius: OvScale.radiusMd))
.background(OvTokens.colorBgRaised, in: .rect(cornerRadius: OvScale.radiusMd))
@@ -59,5 +59,7 @@ theme's tokens it wears (`typealias OvTokens = VibeDarkTokens`). The theme files
@@ -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/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.
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.
@@ -70,8 +70,10 @@ nothing is passed through unchecked:
@@ -70,8 +70,10 @@ nothing is passed through unchecked:
values try to inject code.
values try to inject code.
Status: the generated Kotlin, Swift and Dart are checked against the Compose Material 3, SwiftUI and Flutter APIs by
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)
## Comfort settings (4.45.0)
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -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"
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.45.0",
+ "version": "4.45.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.45.0",
+ "version": "4.45.1",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.45.0",
+ "version": "4.45.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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');