compose-rust 0.0.0

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
# dioxus-compose

[![CI](https://github.com/DarkPyonix/compose-rust/actions/workflows/ci.yml/badge.svg)](https://github.com/DarkPyonix/compose-rust/actions/workflows/ci.yml)
[![Native renderer](https://github.com/DarkPyonix/compose-rust/actions/workflows/native-renderer.yml/badge.svg)](https://github.com/DarkPyonix/compose-rust/actions/workflows/native-renderer.yml)
[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![Rust 1.85+](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org)

**English** ยท [ํ•œ๊ตญ์–ด](docs/locales/README_ko.md)

**Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.**

*Native desktop UI in Rust with no webview and no bundled JVM.*

```rust
rsx! {
    Column {
        fill_max_width: true,
        Text { text: "dioxus-compose chat" }
        Button { text: "Send", on_click: move |_| send() }
    }
}
```

You author components with `rsx!`, hooks and signals. `dioxus-core`'s VirtualDom turns them into
mutations, a narrow C ABI carries those mutations across the boundary, and a Kotlin/Compose
interpreter materialises them as a real Compose tree, with Compose's text layout, its widgets, and
its platform IME.

---

## ๐Ÿ“‘ Contents

- [Why this exists](#-why-this-exists)
- [Status](#-status)
- [A taste of the API](#-a-taste-of-the-api)
- [Design systems](#-design-systems)
- [Architecture](#-architecture)
- [Getting started](#-getting-started)
- [Performance](#-performance)
- [Project layout](#-project-layout)
- [Contributing](#-contributing)
- [Documentation](#-documentation)
- [License](#-license)

---

## ๐ŸŽฏ Why this exists

Two problems meet here.

**Web-stack desktop apps are heavy.** For an application that stays open all day, the memory
footprint and the download size are the problem, not responsiveness. Embedding a browser, or
shipping a JVM alongside your app, costs tens to hundreds of megabytes before your own code runs.

**Rust has no toolkit with Compose-grade text.** The Rust GUI ecosystem renders well, but text
shaping, selection, accessibility and above all **IME** are not at the level Compose reached years
ago. For an app where typing *is* the interface, an IME that works 90% of the time is an app that
does not work. Korean, Japanese and Chinese composition is not a nice-to-have.

dioxus-compose takes Compose's renderer without taking Compose's runtime cost: the Kotlin side is
compiled ahead of time into a native library (Kotlin/Native on macOS, Linux and iOS, a GraalVM
native image where Kotlin has no target that can be linked against Skia), so there is no JVM in the
shipped artifact.

### Weight, roughly

The comparisons are **approximate**, taken from [`docs/INTENT.md`](docs/INTENT.md), and are
order-of-magnitude rather than benchmarks. What this project weighs is measured.

| Approach | Approximate weight | Notes |
|---|---|---|
| Webview stack (Electron, Tauri-class) | Heaviest: a browser engine per app or per system | Rejected by **C1**: memory and size |
| Compose + bundled JVM (jlink) | ~80โ€“120 MB of JVM alone | Rejected by **C2**. AppCDS fixes startup, not size |
| Pure Rust toolkit (Iced-class) | ~10โ€“20 MB | Rejected by **C5**: text and IME maturity |
| **dioxus-compose** | 28.76 MB on macOS, one file | Larger than Iced, far smaller than a webview or JVM stack |

### What it actually weighs

The notepad sample, built for release and stripped. One executable: no runtime beside it, no
virtual machine inside it, and nothing linked but the system's own libraries.

| Platform | Executable | Physical footprint | How it is built |
|---|---|---|---|
| macOS (arm64) | **28.76 MB** | **35.1 MB** | Kotlin/Native, drawing through Metal |
| Linux (x86-64) | **37.93 MB** | not yet measured | Kotlin/Native, drawing through GLX |
| Windows | not yet measured | not yet measured | GraalVM native image, drawing through Direct3D 12 |

For scale, the same sample on macOS before the Kotlin/Native path was four files weighing
93.1 MB with a 56 MB footprint. Most of that was the Java runtime the native image carried.

`NFR-3` asks for under 100 MB distributed and under 56 MB of physical footprint for an empty
window. macOS meets both with room, measured on an application rather than an empty window.
Linux and Windows are not measured for footprint yet, and nothing here is claimed for them.

### Non-negotiables

| ID | Constraint |
|---|---|
| **C1** | No webview: no WKWebView, WebView2, WebKitGTK, Tauri or wry |
| **C2** | No bundled JVM: the JVM is allowed only in the development shell |
| **C3** | No hand-written JNI or cinterop glue; boundary shims are generated |
| **C4** | UI is authored declaratively **in Rust**. Kotlin is a renderer implementation detail |
| **C5** | Compose-grade text, IME and widget quality, never bypassed |

---

## ๐Ÿšฆ Status

A **young project under active development**, built spec-first. It is not published to crates.io and
the API will change.

### Platforms

| Platform | State | Detail |
|---|---|---|
| ๐ŸŽ **macOS (arm64)** | **Works end to end** | Rust host โ†’ C ABI โ†’ Kotlin/Native renderer โ†’ window on screen, one file with no Java runtime in it. The window, its Metal layer and its text input are this renderer's own. Basic Korean IME input works; the full IME checklist (`SPEC ยง6`) is not finished |
| ๐ŸชŸ Windows desktop | Builds and starts | Built with upstream GraalVM 25 and smoke-tested on every renderer change. |
| ๐Ÿง Linux desktop | Builds and starts | Both architectures (x64 and arm64) build under Xvfb in CI and pass a headless startup smoke test. |
| ๐Ÿ“ฑ iOS | Builds and starts | Kotlin/Native `-produce static` exporting the same C symbols. Released as an XCFramework. |
| ๐Ÿค– Android | **Works end to end** | A Kotlin Activity owns the process and the loop, Rust is a cdylib, and the JNI shims on both sides are generated from the schema. `dx build --platform android` is the whole build: the crate carries the renderer's Kotlin, and its build script unpacks it into the generated Gradle project and writes the Activity that hosts it, so an application's `Dioxus.toml` says nothing about the renderer. Sample APKs are built that way and run on an emulator (`PR-5`, milestone M6) |
| ๐ŸŒ Web (wasm) | **Works end to end** | One `WebAssembly.Memory`, defined by the Kotlin/Wasm module and imported by the Rust one, so a batch is read where it was written and nothing is copied. Renderer to Host calls cross a generated JavaScript forwarder, measured at 12 ns, because a browser will not give you a shared memory and a direct binding at the same time; the other direction has no JavaScript on it. The M0 screen comes up in a browser from the same Rust source the desktop demo runs, a click reaches the Rust handler, and the state change appears on the page. Tested and photographed in a browser by `web/scripts/test-boundary.sh` and `screenshot.sh` (`PR-6`, milestone M7) |

### The two halves

The Rust **Host** is well ahead of the Kotlin **Renderer**. The Compose interpreter is the piece
being finished right now, so several requirements pass on the Host side while the Renderer half is
still landing.

| Capability | Host (Rust) | Renderer (Kotlin) |
|---|---|---|
| Node tree mutations: `FR-1` | โœ… | โœ… |
| Schema-driven rendering: `FR-2` | โœ… | โœ… 33 composables, all implemented |
| Synchronous event dispatch: `FR-3`, `FR-12` | โœ… | โœ… `on_click`, `on_change`, `on_dismiss`, key consumption |
| Uncontrolled `TextField`, IME ownership: `D5` | โœ… | โœ… |
| Schema codegen in lockstep: `FR-7` | โœ… | โœ… generated `Protocol.gen.kt` |
| `LazyColumn` windowing: `FR-8` | โœ… | โœ… renderer asks for a range |
| Streaming text `AppendText`: `FR-9` | โœ… | โœ… coalesced per frame |
| Modifiers: `FR-10` | โœ… | โœ… 13 attributes on every widget |
| Design primitives and design systems: `FR-13`, `FR-14` | โœ… | โœ… Seven systems, role-based contract |
| Window size classes | โœ… | โœ… `use_window_size()` reports classes |
| Navigation and sheets | โœ… | โœ… Rail, drawer, bottom bar, sheets |
| Third-party widget extension: `FR-11` | โš ๏ธ Compile time only | โš ๏ธ `LinearProgressIndicator` as worked example |

Milestones live in [`PROJECT.md`](PROJECT.md) (M0โ€“M8). **M1 decides the project**: if Korean IME
composition holds up in a native-image build, the rest is volume of work.

---

## โœจ A taste of the API

The snippet below is the real
[`dioxus-compose/examples/desktop_demo.rs`](dioxus-compose/examples/desktop_demo.rs), trimmed for
length. It compiles in this repository.

```rust
use compose_rust::prelude::*;

fn app() -> Element {
    let mut messages = use_signal(Vec::<String>::new);
    let mut draft = use_signal(String::new);

    rsx! {
        Column {
            fill_max_width: true,
            Text { text: "dioxus-compose chat" }
            for message in messages() {
                Text { text: message }
            }
            TextField {
                placeholder: "Write a message",
                multiline: true,
                on_value_change: move |value| draft.set(value),
                on_key_down: move |event: KeyEvent| {
                    if event.key() == Key::Enter && !event.shift_key() {
                        let message = draft().trim().to_owned();
                        if !message.is_empty() {
                            messages.write().push(message);
                            draft.set(String::new());
                        }
                        event.consume();   // the Renderer's onKeyEvent returns true
                    }
                }
            }
            Button {
                text: "Send",
                on_click: move |_| { /* ... */ }
            }
        }
    }
}

fn main() {
    compose_rust::launch(app);
}
```

Two details worth noticing:

- **`event.consume()`** is how a handler tells the Renderer it handled the key, the same idea as
  `preventDefault()` on the web, or `PointerInputChange.consume()` in Compose. Dioxus 0.7 handlers
  have no return value, so the flag rides back on the event object (`FR-12`).
- **Key events are not sent to Rust while an IME composition is in progress.** Enter during
  composition commits the composition; it does not submit. Getting this wrong is exactly how Korean
  input loses the syllable being typed.

> โš ๏ธ `compose_rust::Box` has to be written qualified inside `rsx!`, `dioxus-core` 0.7's macro
> expansion uses an unqualified `Box<T>`, which the prelude glob would shadow.

---

## ๐ŸŽจ Design systems

The plan is seven first-class design systems, including **Material 3**, **Apple HIG**, **WinUI/Fluent**, and **Liquid Glass**, chosen per application, either unified across every platform or adapted to the host platform:

```rust
// The same design system everywhere
LaunchBuilder::new().with_theme(Theme::unified(DesignSystem::Material3)).launch(app);

// Follow the host platform. The fallback argument is mandatory
LaunchBuilder::new().with_theme(Theme::adaptive(DesignSystem::Material3)).launch(app);
```

The design is worked out in detail in `FR-13` and `FR-14` of [`docs/SPEC.md`](docs/SPEC.md):

- Widgets emit **roles**, never literals: `ColorRole`, `TypeRole`, `ShapeRole`, `SpaceRole`, and
  component variants such as `ButtonVariant::{Filled, Tonal, Outlined, Text}`.
- **The Renderer resolves roles into tokens**, not the Host. A dark-mode switch is then one
  `SetTheme` mutation plus a `CompositionLocal` invalidation, instead of an `O(nodes)` storm of
  `SetProp` calls charged against the frame budget.
- Adding an eighth design system must not touch widget code, properties, modifiers or the wire
  format: one Rust enum variant, one Kotlin token table, one rules implementation.
- `adaptive` is **not** the default. Without `with_theme` you get
  `Theme::unified(DesignSystem::Material3)`, because a default that looks different on every
  platform is a bad default.

> **Status: `Draft`, design only.** โš ๏ธ None of this exists in code yet. `Theme`, `DesignSystem`,
> `ColorRole`, `TypeRole` and `ScrollColumn` appear nowhere in `dioxus-compose/src/` or in the Kotlin
> renderer today. What ships now is the literal subset: `Modifier::Background(u32 ARGB)`,
> `Modifier::Padding(f32)` and friends. The snippet above is **illustrative of the specified API**,
> not of a working one.

---

## ๐Ÿ— Architecture

```
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Host (Rust) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  your components, rsx!, hooks, signals                  โ”‚
โ”‚  dioxus-core VirtualDom                                  โ”‚
โ”‚  dioxus-compose renderer:  Mutations โ”€โ”€โ–บ fixed-layout    โ”‚
โ”‚                                          byte records    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
             synchronous, same-thread direct calls
             (JSI-style) + one batch buffer per call
                            โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  generated shims  (@CEntryPoint / @CName / JNI / wasm)   โ”‚
โ”‚  protocol decoder โ”€โ”€โ–บ node table (Compose snapshot state)โ”‚
โ”‚  schema interpreter  @Composable RenderNode              โ”‚
โ”‚  Compose Desktop (AWT) ยท Compose iOS (UIKit)             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Renderer (Kotlin) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
```

**Rust describes the UI; Compose interprets it.** Rust never calls the Compose API directly, it
cannot. GraalVM's `@CEntryPoint` passes only primitives and word-sized values, so objects like
`Modifier` or `MutableState` can never cross. Instead the UI tree travels **as a value** and a
general-purpose interpreter on the Kotlin side rebuilds it. Cash App's Redwood and Jetpack Glance
use the same pattern.

**The boundary is synchronous and same-thread** (`PR-1`). The VirtualDom runs on the Renderer's UI
thread and the two sides call each other directly, the way JSI replaced React Native's old bridge.
There is no queue, no ring buffer and no thread hop, and an event handler can return a result within
the same call. Heavy work, I/O, network, PTY, runs on Host worker threads that update signals and
request a frame (`PR-3`); user code never touches a boundary function.

**Only primitives, pointers and lengths cross** (`PR-2`). Payloads are fixed-layout, zero-copy
records produced by codegen and read in place, no postcard, bincode or JSON on the hot path
(`PR-4`). Type safety is restored above that `bytes` boundary by generating the Kotlin types from a
single Rust source of truth, with a schema hash that fails the build when the two drift (`FR-7`,
`D6`).

**UI-local state stays in Kotlin** (`D5`). `TextField` is uncontrolled and composition text never
makes a round trip through Rust. Scroll position, focus and animation state belong to the Renderer
too.

<details>
<summary><b>Why the native image needs Liberica NIK, and three small shims</b></summary>

This is the GraalVM native image, which is how Windows is built and how macOS was built before the
Kotlin/Native path. The renderer macOS ships links no AWT and needs none of what follows.

Compose Desktop's window is an AWT `JFrame`, and AOT compilation does not change which code path
runs, so the AWT IME path survives native-image (`D4`). But upstream GraalVM **skips AWT entirely
on Darwin** ([oracle/graal#13272](https://github.com/oracle/graal/issues/13272)), which means no
static AWT archive and no way to link the renderer. Liberica NIK Full links AWT statically.

Statically linked macOS AWT then looks for three things by file path at runtime, each met by a thin
shim in `dioxus-compose-renderer/desktop/c/`:

| Missing thing | Shim |
|---|---|
| `libawt_lwawt.dylib`, which libawt loads by path | A placeholder dylib; the JNI functions resolve inside the executable image |
| `libjawt.dylib`, which Skiko `dlopen`s from `<java.home>/lib` | A forwarder into the image's own `JAWT_GetAWT` |
| `JNI_OnLoad_osxui`, required of a statically linked JNI library and absent from NIK's archive | Defined directly |

Also: **AppKit demands the main thread.** The renderer runs on a secondary thread while the main
thread creates and runs `NSApplication` itself, putting AWT into embedded mode, the same mode SWT
and JavaFX hosts use. Letting AWT own the loop re-enters `[NSApp run]` forever, and control never
returns to the Host after the window closes.

The JNI mentioned here is entirely internal to the JDK. The Host โ†” Renderer boundary is pure C ABI.
</details>

---

## ๐Ÿš€ Getting started

### Using it in your own project

One line. `cargo build` works out which renderer this target needs, downloads the release
artifact for the crate's exact version, checks it against the published `.sha256`, unpacks it
into a cache outside `target/`, and links it.

```toml
[dependencies]
compose-rust = "0.0.0"
```

There is no environment variable to set, no artifact to fetch by hand and no script to run. The
cache is keyed by version and target, so it survives `cargo clean` and is shared between projects
on the machine.

Two variables exist for the cases that need them, and neither is part of installing:

| Variable | Effect |
|---|---|
| `DIOXUS_COMPOSE_RENDERER_DIR` | Use the renderer in this directory. Checked first, and nothing is downloaded when it is set, so a renderer you built yourself, a vendored copy or an air-gapped build all work through it. |
| `DIOXUS_COMPOSE_CACHE_DIR` | Move the cache off `$HOME/.cache/dioxus-compose` (`%LOCALAPPDATA%\dioxus-compose` on Windows). |

A build with no network says which two files to put where, and putting them there is all it takes.
`default-features = false` builds with no renderer at all, for a headless or documentation build;
running a binary built that way prints what is missing and exits non-zero rather than opening no
window and returning 0.

Everything below this point is about working on **this repository**, which needs the renderer
toolchain as well.

### 0. Check your machine

```bash
./scripts/setup-check.sh          # --quiet for CI-style output
```

This verifies every tool the build needs and prints the exact fix for anything missing. Start here;
everything below assumes it passes.

### 1. Rust

Install through [rustup](https://rustup.rs). `scripts/check.sh` runs `cargo fmt` and `cargo clippy`,
so both components are required. The workspace targets Rust **1.85+** (edition 2024).

```bash
rustup component add rustfmt clippy
```

### 2. Liberica NIK 25 **Full**, only for the GraalVM native image

Not needed to build the renderer macOS ships. That one is Kotlin/Native, and what it needs is the
patched Compose in step 3.

> โš ๏ธ **Upstream GraalVM does not work on macOS.** It skips AWT support on Darwin
> ([oracle/graal#13272](https://github.com/oracle/graal/issues/13272), still open as of 2026-09), so
> Compose Desktop cannot be linked into the image. Use BellSoft **Liberica NIK 25 Full**, the
> *Full* variant, not the standard one.

```bash
brew install --cask liberica-nik-full
# or download "NIK 25 Full" from https://bell-sw.com/pages/downloads/native-image-kit/
# or, scripted and version-pinned (macOS only):
./scripts/install-nik.sh
```

`dioxus-compose-renderer/desktop/scripts/env.sh` discovers it in this order:

1. `$GRAALVM_HOME`, if set
2. the newest match of
   `~/Library/Java/JavaVirtualMachines/bellsoft-liberica-vm-full-openjdk25*/Contents/Home`

Development uses `bellsoft-liberica-vm-full-openjdk25-25.0.4.1`. The scripts **reject** any install
without `lib/static/darwin-*/libawt_lwawt.a`. That catches plain GraalVM up front, instead of after
a long build ends in a link failure.

macOS also needs the Xcode command line tools (`xcode-select --install`) for `cc`, `ld` and the
AppKit headers used by `dioxus-compose-renderer/desktop/c/`.

**The scripts support macOS only today.** Linux and Windows native-image builds are not scripted.

### 3. The patched Compose

The renderer resolves Compose from your local Maven repository, not from what JetBrains published.
Three things it needs are empty in the published build for this platform and cannot be filled from
outside the module that holds them: the entries a text selection's menu offers, the keys that copy,
and the Linux targets. The patches are in `dioxus-compose-renderer/patches/`, pinned to one Compose
revision, and each one says what it is for.

```bash
./dioxus-compose-renderer/scripts/build-compose.sh
```

It fetches the pinned revision, applies the patches, and publishes the modules the renderer asks
for. Slow, and run once rather than once per build. Everything else still resolves from what
JetBrains published.

### 4. Kotlin

Nothing to install. `dioxus-compose-renderer/kotlin` (`kotlin.bat` on Windows) is a
self-bootstrapping wrapper that downloads the pinned toolchain on first use.

### 5. Build the renderer

Produces one static library, with Compose, Skia and the interpreter inside it, in
`dioxus-compose-renderer/build/macos/` (`PR-8`). Takes several minutes.

```bash
cd dioxus-compose-renderer
./desktop/scripts/build-macos.sh --release
```

```
build/macos/
  libdioxus_compose_renderer.a       the renderer (Compose, Skia, the interpreter, our code)
  libdioxus_compose_renderer_api.h   the header Kotlin/Native generates for it
```

<details>
<summary><b>The GraalVM native image, and what lands in <code>dist/lib/</code></b></summary>

`./desktop/scripts/build-native.sh` builds the native image instead. That is how Windows is built
and what `smoke-test.sh` links against, and it is no longer how macOS ships. Its output is a
directory rather than one file, because statically linked AWT resolves some things by path:

```
build/native-image/dist/lib/
  libdioxus_compose_renderer.dylib   the renderer (AWT, Skiko JNI, Compose, our code)
  libskiko-macos-<arch>.dylib        Skia, loaded by Skiko by path
  libjawt.dylib                      forwards JAWT_GetAWT into the renderer
  libawt_lwawt.dylib                 placeholder that libawt loads by path
```
</details>

### 6. Smoke-test it

Links a minimal C host against the library and calls `dioxus_compose_renderer_run`. A window should
open, and closing it should return 0, the `PR-8` acceptance criterion.

```bash
cd dioxus-compose-renderer
./desktop/scripts/smoke-test.sh
```

For an unattended run, set `DIOXUS_COMPOSE_AUTOEXIT_MS=6000` to make the window close itself.

### 7. Run the Rust demo

```bash
cargo run -p compose-rust --example desktop_demo --features native-renderer
```

In a checkout of this repository the build script prefers the renderer you just built, at
`dioxus-compose-renderer/build/native-image/dist/lib`, over anything it could download. The full
order is `DIOXUS_COMPOSE_RENDERER_DIR`, then that workspace build, then the cache, then the release
for the crate's version (`NFR-10`).

### 8. The JVM dev shell

The fastest loop when you are working on the renderer itself: hot reload and `@Preview` work, and no
native-image build is needed (`NFR-5`, `D7`).

```bash
cd dioxus-compose-renderer
./kotlin run -m desktop   # the Compose development shell
./kotlin run -m desktop    # the renderer module itself on the JVM, driven by a scripted Host
```

> The JVM is permitted **only here**. Shipped artifacts never contain one (`C2`).

---

## ๐Ÿ“Š Performance

The goal (`NFR-9`) is to be **indistinguishable from the same screen written by hand in
Kotlin/Compose**. The reference is a 120 Hz display: 8.33 ms per frame.

### Budgets, `SPEC ยง5.1`

| Item | Budget (p99, release build) |
|---|---|
| Overhead versus a pure-Compose baseline | โ‰ค 10% frame time |
| Host work (handler + diff + batch encode) | โ‰ค 0.5 ms normal interaction, โ‰ค 1 ms streaming frame |
| One boundary call | โ‰ค 100 ns desktop/iOS, โ‰ค 200 ns Android with `@FastNative` |
| Batch apply (decode + snapshot apply) | โ‰ค 0.3 ms per 100 mutations |
| Input โ†’ pixels | Same frame count as the baseline; zero extra frames of latency |
| Steady-state allocation | 0 in boundary encoding (arena reuse); โ‰ค 200 per frame across the Host path, and not growing |
| Dropped frames | 0 while streaming at 100 appends/s and scrolling a 10,000-item list |

Budget regressions are treated as bugs, and CI fails the build on them.

### Measured, 2026-09-20

Recorded in [`dioxus-compose/benches/baseline.json`](dioxus-compose/benches/baseline.json) and
re-run by `cargo bench` inside `scripts/check.sh`.

> **Machine:** Mac mini (Macmini9,1) ยท Apple M1, 8 cores (4 performance + 4 efficiency) ยท 16 GiB ยท
> macOS 26.5.1 (25F80) ยท `aarch64-apple-darwin` ยท rustc 1.98.1 ยท release profile.

| Measurement | Result | Budget |
|---|---|---|
| Click โ†’ dispatch โ†’ diff โ†’ encode | **13.3 ยตs** p99 | โ‰ค 500 ยตs |
| Encode 100 mutations | **2.9 ยตs** p99, **0** steady-state allocations | โ‰ค 0.3 ms |
| Streaming: 100 appends into a 10,000-message conversation | **220 ยตs** p99 | โ‰ค 1 ms |
| Allocations per interaction, whole Host path | **99** | โ‰ค 200, and must not grow |

Those 99 allocations are Dioxus's own, inside diffing and event handling. Driving them to zero would
mean forking Dioxus, which contradicts `D2`; Rust has no GC, so they do not turn into frame pauses.
The criterion is that the number **does not grow** across repeated identical interactions, growth is
treated as a leak or a dead cache and investigated.

These are **Host-side numbers**. Renderer-side frame timing, and the 10%-versus-baseline comparison,
still have to be measured on the native-image build.

---

## ๐Ÿ—‚ Project layout

```
dioxus-compose/
โ”œโ”€ dioxus-compose/                  # Rust: the Host, Dioxus renderer crate
โ”‚  โ”œโ”€ src/
โ”‚  โ”‚  โ”œโ”€ lib.rs                     #   public API, rsx! elements, event attributes
โ”‚  โ”‚  โ”œโ”€ widgets.rs                 #   Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn
โ”‚  โ”‚  โ”œโ”€ schema.rs                  #   the single source of truth for the wire schema
โ”‚  โ”‚  โ”œโ”€ protocol.rs                #   fixed-layout encoding (PR-4)
โ”‚  โ”‚  โ”œโ”€ boundary.rs                #   C ABI surface, launch / LaunchBuilder
โ”‚  โ”‚  โ””โ”€ codegen.rs                 #   Rust schema โ†’ Kotlin types
โ”‚  โ”œโ”€ examples/desktop_demo.rs      #   the runnable demo
โ”‚  โ”œโ”€ benches/baseline.json         #   the recorded performance baseline
โ”‚  โ””โ”€ tests/vectors/                #   protocol vectors both sides assert against
โ”œโ”€ dioxus-compose-renderer/         # Kotlin: the Renderer (Kotlin Toolchain / Amper)
โ”‚  โ”œโ”€ native/                       #   the interpreter, C shims, native-image build scripts
โ”‚  โ”œโ”€ desktop/                      #   JVM development shell
โ”‚  โ”œโ”€ shared/                       #   shared Compose code
โ”‚  โ””โ”€ ios/  android/  web/          #   platform targets
โ”œโ”€ samples/                         # eleven sample apps (four adaptive, seven unified)
โ”œโ”€ scripts/                         # setup-check.sh, check.sh, install-nik.sh, publish-main.sh
โ””โ”€ docs/
   โ”œโ”€ INTENT.md                     # why, decisions D1โ€“D10, rejected alternatives
   โ”œโ”€ SPEC.md                       # FR-*, NFR-*, PR-* with acceptance criteria
   โ”œโ”€ guide/                        # the user guide site (hand-written HTML, en + ko)
   โ””โ”€ locales/README_ko.md          # this README, in Korean
```

---

## ๐Ÿค Contributing

### Spec Driven Development

**The SPEC is the source of truth.** Before implementing a behaviour, find its SPEC ID (`FR-*`,
`NFR-*`, `PR-*`). If none exists, amend the SPEC first, in its own commit. If code and SPEC disagree,
the code is wrong, unless the SPEC is, in which case fix the SPEC first and explain why. Decisions
change in [`docs/INTENT.md`](docs/INTENT.md) first, then SPEC, then code.

### Test Driven Development

SDD says what to build; TDD is how it gets built. Tests come from acceptance criteria, so **a
requirement with no test is not done**.

- Red, green, refactor. Test and implementation land in the **same commit**, the tree builds green
  at every commit.
- Name tests after the requirement: `fr4_set_prop_does_not_recompose_siblings`,
  `pr2_batch_applies_atomically`.
- A bug fix starts with a test that reproduces the bug.
- Test through the public surface: the crate API and the C exports; the interpreter and
  `HostConnection`.
- Use the checked-in protocol vectors and `FakeHostConnection`, not mocks of our own protocol.
- Performance is a test too: ยง5.1 is a benchmark suite, and the allocation ceiling is an assertion.

IME (`ยง6`) and accessibility (`ยง7`) are manual checklists run on the **native-image build**, and the
SPEC says so explicitly rather than leaving them silently untested.

### The quality gate

```bash
./scripts/check.sh              # fmt, clippy, tests, quick benchmarks, Kotlin build and tests
./scripts/check.sh --full       # the same, with the full benchmark sample
./scripts/check.sh --no-kotlin  # Rust only (DXC_SKIP_KOTLIN=1 does the same)
```

CI mirrors that split. [`ci.yml`](.github/workflows/ci.yml) runs the Rust gate on macOS and Linux for
every push and pull request, while [`native-renderer.yml`](.github/workflows/native-renderer.yml)
builds the native-image renderer and runs the C smoke test on pushes to `main`/`develop`, nightly,
and on demand, that build needs a ~1 GB NIK download and tens of minutes, which is too slow to put
in front of every push (`NFR-5`, `D7`).

### Commits

One logical change per commit; do not mix SPEC edits, refactors and features. Subject format:

```
<Type>: <imperative summary>
```

`Feat` ยท `Fix` ยท `Refactor` ยท `Docs` ยท `Test` ยท `Chore`. Reference SPEC IDs where relevant, for
example `Feat: Return handler result from dispatch_event (PR-2)`. Do **not** add `Co-Authored-By`
trailers or any AI attribution.

---

## ๐Ÿ“š Documentation

**๐Ÿ“– Guide site: <http://darkpyonix.dev/dioxus-compose/>**, English and Korean, covering getting
started, writing UI, lists and streaming, architecture and troubleshooting.

| Document | What is in it |
|---|---|
| [PROJECT.md](PROJECT.md) | Scope, method, milestones M0โ€“M8, open questions |
| [docs/INTENT.md](docs/INTENT.md) | Motivation, non-negotiables, decisions D1โ€“D10, rejected alternatives |
| [docs/SPEC.md](docs/SPEC.md) | Functional and non-functional requirements, boundary protocol, acceptance criteria |
| [CLAUDE.md](CLAUDE.md) | Working agreements for this repository |

The planning documents (`PROJECT.md`, `INTENT.md`, `SPEC.md`) are written in Korean. This README and
the guide site are English, with Korean translations.

---

## ๐Ÿ“„ License

[Apache License 2.0](LICENSE).