Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Nebula

Nebula Demo

KotlinComposePlatformsMaven CentralLicense

Nebula

Server-driven native UI for Kotlin Multiplatform. Two rendering paths — JSON for rapid prototyping, binary wire protocol for production performance. All platforms.

Two Rendering Paths

PathFormatLatencyUse Case
JSONHuman-readable JSON~ms parsePrototyping, CMS-driven UI, A/B tests
Binary ProtocolCompact byte stream~us parseProduction apps, real-time updates, animations

Both render as native Material 3 composables. No custom layout engine — Compose handles layout, text shaping, and accessibility natively.

Modules

io.github.androidpoet:nebula:0.1.0 // JSON SDUI
io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose)
io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL
io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents

Pick only what you need:

// Client app — render binary documents from your server
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
// Server/backend — create binary documents (no Compose dependency)
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")
// JSON SDUI path
implementation("io.github.androidpoet:nebula:0.1.0")
// Custom tooling — direct wire buffer access
implementation("io.github.androidpoet:nebula-protocol:0.1.0")

Binary Wire Protocol

A compact binary format modeled after AndroidX Compose Remote. Server creates a document as bytes, client renders it as native Compose UI.

Server Side — Create Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-creation:0.1.0")

Kotlin DSL:

val bytes = remoteDocument(width =400f, height =800f) {
val titleStyle = textStyle {
fontSize =24f
fontWeight =700
color =0xFF1A1A1A
}
val bodyStyle = textStyle { fontSize =16f }
column(spacing =16f) {
modPadding(24f)
modBackground(0xFFF5F5F5, cornerRadius =16f)
layoutText(text("Welcome to Nebula"), titleStyle)
layoutText(text("Server-driven native UI"), bodyStyle)
row(spacing =8f) {
button("Get Started") { hostNamedAction("navigate", "{\"route\":\"onboarding\"}") }
button("Learn More") { hostNamedAction("open_url", "{\"url\":\"https://github.com/AndroidPoet/nebula\"}") }
}
}
}
// Send `bytes` over HTTP, WebSocket, gRPC, etc.

Reactive Expressions:

val bytes = remoteDocument(400f, 800f) {
// Animated float using RPN expressions with operator overloadingval pulse = rFloat { (CONTINUOUS_SEC* const(2f)) % const(1f) }
canvas(200f, 200f) {
paint(color =0xFF6750A4)
drawCircle(100f, 100f, 50f)
}
}

Canvas Drawing:

val bytes = remoteDocument(400f, 400f) {
canvas(width =400f, height =400f) {
paint(color =0xFFFF0000, strokeWidth =2f)
drawRect(10f, 10f, 190f, 190f)
drawRoundRect(200f, 10f, 390f, 190f, rx =12f)
drawCircle(100f, 300f, 80f)
drawLine(200f, 220f, 390f, 390f)
withTransform {
translate(200f, 200f)
rotate(45f)
drawRect(0f, 0f, 100f, 100f)
}
}
}

Client Side — Render Documents

// build.gradle.kts
implementation("io.github.androidpoet:nebula-protocol-player:0.1.0")
NebulaRemote(
bytes = wireBytes,
onAction = { id -> handleAction(id) },
onNamedAction = { name, metadata -> handleNamedAction(name, metadata) },
)

The player handles layout, text, modifiers, animations, theming, and actions — all rendered as native Compose composables.

Protocol Architecture

Server (any JVM/Kotlin target) Client (Compose Multiplatform)
────────────────────────── ──────────────────────────────
remoteDocument { ... } NebulaRemote(bytes)
│ │
RemoteComposeWriter CoreDocument
│ │
WireBuffer ┌──────┴──────┐
│ │ │
ByteArray ──── network ────→ DATA pass PAINT pass
(resources) (render tree)
│ │
RemoteContext Compose UI

Wire format: 1-byte opcodes, size-prefixed blocks, NaN-encoded float variable IDs (IEEE 754), RPN expression engine with 71 operators.

Dual-pass execution:

  1. DATA pass — Loads text, colors, floats, named variables, expressions
  2. PAINT pass — Walks the component tree and renders via Compose

Protocol Features

CategoryOperations
LayoutColumn, Row, Box, Canvas, Flow, FitBox, CollapsibleRow/Column, State, Content
TextText, TextStyle (size, weight, color, spacing, alignment, italic, decoration), TextMerge, TextFromFloat
ModifiersWidth, Height, Padding, Background, Border, ClipRect, RoundedClip, Click, Visibility, Offset, Scroll, ZIndex, GraphicsLayer, TouchDown/Up
DrawRect, RoundRect, Circle, Oval, Line, Arc, Path, Bitmap, TextRun, TextAnchor, ClipRect/Path
TransformSave, Restore, Translate, Rotate, Scale, Skew
DataFloat, AnimatedFloat, Text, Int, Boolean, Long, Color, NamedVariable, FloatList, ColorExpression, IntegerExpression, TouchExpression
ActionsHostAction, HostNamedAction, ValueIntegerChange, ValueFloatChange, RunAction, Conditional, LoopStart, Skip
AnimationLinear, EaseIn, EaseOut, EaseInOut, Spring, Overshoot, Bounce, Anticipate + spring physics engine
Expressions71 RPN operators: arithmetic, trig, rounding, comparisons, logic, easing, waves, stack manipulation

JSON SDUI

How It Works

Backend JSON Native UI
───────────── ─────────
{ "type": "column", Column {
"spacing": 16, → Text("Welcome back")
"children": [ Card { ... }
{ "type": "text", ... }, Button("Get Started")
{ "type": "card", ... }, }
{ "type": "button", ... }
]
}

The backend defines the entire UI as a JSON component tree. Nebula walks the tree and renders each node as a native Compose composable.

Setup

// build.gradle.kts
implementation("io.github.androidpoet:nebula:0.1.0")

Render JSON as Native UI

Nebula(json = serverResponse) { action ->when (action) {
isNebulaAction.OpenUrl-> openBrowser(action.url)
isNebulaAction.Custom-> handleEvent(action.name, action.data)
isNebulaAction.Navigate-> navController.navigate(action.route)
else-> {}
}
}

With Variables

Nebula(
json = serverResponse,
variables =mapOf(
"user.name" to "Ranbir Singh",
"user.plan" to "Pro",
"stats.projects" to "42",
),
)

Variables resolve {{ user.name }}Ranbir Singh in any text component. They're reactive — update the store and the UI recomposes.

Custom Image Loader

Nebula(
json = serverResponse,
imageLoader = { url, contentDescription, modifier ->AsyncImage(
model = url,
contentDescription = contentDescription,
modifier = modifier,
)
},
)

Nebula doesn't bundle an image loader — bring your own (Coil, Kamel, etc.).

Custom Components

val registry = remember { NebulaRegistry() }
registry.register("video_player") { component ->VideoPlayer(
url = component.properties["url"]?.jsonPrimitive?.content ?:"",
)
}
Nebula(json = serverResponse, registry = registry)

Register any composable for custom component types. The backend sends {"type": "custom", "type": "video_player", "properties": {...}} and your renderer handles it.

27 Built-in Components

Layout

ComponentRenders AsPurpose
columnColumnVertical layout with spacing & alignment
rowRowHorizontal layout with spacing & alignment
boxBoxOverlay/stack layout with content alignment
lazy_columnLazyColumnScrollable vertical list
lazy_rowLazyRowScrollable horizontal list
flow_rowFlowRowWrapping horizontal layout
flow_columnFlowColumnWrapping vertical layout
spacerSpacerFlexible or fixed spacing

Display

ComponentRenders AsPurpose
textTextMaterial 3 typography with variable resolution
imageCustom loaderRemote/local images via your image loader
iconIconNamed icons with tint and size
dividerHorizontalDividerSeparator line
progress_indicatorCircular/LinearDeterminate or indeterminate progress
badgeBadgeNotification badge with optional label

Interactive

ComponentRenders AsPurpose
buttonButton5 styles: filled, outlined, elevated, text, tonal
icon_buttonIconButtonTappable icon
text_fieldOutlinedTextFieldText input with label and placeholder
checkboxCheckboxToggle with label
switchSwitchToggle switch with label
sliderSliderRange input with min/max/steps

Container

ComponentRenders AsPurpose
cardCardElevated container with shape and color
scaffoldScaffoldApp structure with top bar, bottom bar, FAB
top_app_barTopAppBarTitle, navigation icon, actions

Meta

ComponentRenders AsPurpose
conditionalShow/hide based on variable truthiness
customYour composableExtensible via NebulaRegistry

Variable Templates

{{ user.name }} → Ranbir Singh
{{ stats.projects }} → 42
{{ product.price }} → $9.99

Variables live in a reactive VariableStore. Update a value and every text referencing it recomposes automatically.

Modifier System

{
"type": "box",
"modifier": {
"fillMaxWidth": true,
"padding": { "all": 16 },
"background": "#6750A4",
"shape": { "type": "rounded", "cornerRadius": 24 },
"shadow": { "elevation": 8 },
"border": { "width": 1, "color": "#FFFFFF" },
"alpha": 0.9,
"rotate": 5,
"clickAction": { "type": "custom", "name": "tapped" }
}
}

Supports: size, padding, background, shape, border, shadow, scroll, alpha, clip, rotation, scale, offset, and click actions.

Actions

ActionPurpose
navigateNavigate to a route
backGo back
open_urlOpen URL in browser
set_valueUpdate a variable
customNamed event with data payload
multiExecute multiple actions in sequence
snackbarShow a snackbar message

Targets

PlatformTargetStatus
AndroidandroidTargetStable
iOSiosArm64, iosX64, iosSimulatorArm64Stable
macOSmacosArm64, macosX64Experimental
Desktopjvm("desktop")Stable

Architecture

nebula/
├── nebula-core/ ← JSON SDUI (io.github.androidpoet:nebula)
│ └── commonMain/
│ ├── Nebula.kt ← Entry point composable + JSON parser
│ ├── components/
│ │ ├── NebulaComponent.kt ← 27 sealed component types
│ │ ├── NebulaModifier.kt ← Unified modifier model
│ │ ├── NebulaAction.kt ← 7 action types
│ │ ├── TextStyle.kt ← Typography with M3 roles
│ │ └── Enums.kt ← Alignment, arrangement, etc.
│ ├── renderer/
│ │ ├── NebulaRenderer.kt ← Recursive component → Compose mapper
│ │ ├── ModifierResolver.kt ← NebulaModifier → Compose Modifier
│ │ ├── ColorResolver.kt ← Hex, named, Material colors
│ │ └── NebulaRegistry.kt ← Custom component registration
│ └── variable/
│ ├── VariableStore.kt ← Reactive variable storage
│ └── VariableResolver.kt ← {{ template }} resolution
│
├── nebula-protocol/ ← Binary wire protocol (io.github.androidpoet:nebula-protocol)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ ├── WireBuffer.kt ← Binary read/write with size-prefixed blocks
│ ├── core/
│ │ ├── Operations.kt ← 130+ opcodes (1-byte, matches AndroidX)
│ │ ├── NanEncoding.kt ← IEEE 754 NaN-encoded variable IDs
│ │ ├── Operation.kt ← Base class + RemoteContext
│ │ ├── ComponentOperation.kt ← Container/leaf component model
│ │ └── CoreDocument.kt ← Document parser, tree inflation, dual-pass execution
│ ├── engine/
│ │ ├── FloatExpression.kt ← RPN evaluator with 71 operators
│ │ ├── FloatAnimation.kt ← 8 easing types
│ │ └── SpringStopEngine.kt ← Damped spring physics
│ └── operations/
│ ├── ProtocolOps.kt ← Header, theme, debug, haptics, a11y
│ ├── DataOps.kt ← Float, text, color, expressions
│ ├── DrawOps.kt ← Rect, circle, arc, path, bitmap
│ ├── TransformOps.kt ← Matrix save/restore/translate/rotate/scale
│ ├── LayoutOps.kt ← Column, row, box, canvas, flow, text
│ ├── ModifierOps.kt ← Padding, background, border, click, scroll
│ └── ActionOps.kt ← Host actions, conditionals, loops
│
├── nebula-protocol-creation/ ← Authoring API (io.github.androidpoet:nebula-protocol-creation)
│ └── commonMain/ Pure Kotlin — no Compose dependency
│ └── creation/
│ ├── RemoteComposeWriter.kt ← Low-level procedural API
│ └── RemoteComposeContext.kt ← Kotlin DSL (remoteDocument { })
│
├── nebula-protocol-player/ ← Compose renderer (io.github.androidpoet:nebula-protocol-player)
│ └── commonMain/
│ └── player/
│ └── RemoteComposePlayer.kt ← NebulaRemote() composable
│
└── sample/ ← Desktop demo app

Tech Stack

LayerLibrary
UICompose Multiplatform 1.7.3
DesignMaterial 3
Serializationkotlinx.serialization 1.7.3
Asynckotlinx.coroutines 1.9
BuildKotlin 2.1.0, Gradle 8.9

Zero heavy dependencies. No networking library. No image loader. Just Compose + serialization.

Build

# All desktop targets
./gradlew :nebula-core:compileKotlinDesktop :nebula-protocol:compileKotlinDesktop :nebula-protocol-creation:compileKotlinDesktop :nebula-protocol-player:compileKotlinDesktop
# Run protocol tests (151 tests)
./gradlew :nebula-protocol:desktopTest :nebula-protocol-creation:desktopTest
# Run sample
./gradlew :sample:run

Contributing

Contributions are welcome! If you've found a bug, have an idea for an improvement, or want to contribute new features, please open an issue or submit a pull request.

Find this repository useful? ❤️

Support it by joining stargazers for this repository. ⭐
Also, follow me on GitHub for my next creations! 🤩

License

Copyright 2026 androidpoet (Ranbir Singh)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Server-driven native UI for Kotlin Multiplatform — JSON to Compose, no WebView

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages