compose-rust guide

Design systems

Roles, not pixels

A widget set is not a design. Seven design systems turn Column and Button into something that looks like a Mac, Android or Windows application. Widgets emit roles, and the renderer resolves them to the active system's tokens.

Roles first, literals as the escape hatch

A widget emits a role: "this is a title", "this is the surface colour", "this is medium spacing". The renderer decides what that role looks like. A literal value is always available too, and it always means "draw exactly this".

The reason is the frame budget. The Host runs on the renderer's UI thread, so any work it does comes out of the interaction's budget. If the Host resolved tokens, switching to dark mode would re-send a property for every affected node. With the renderer resolving them, the same switch is one theme record, and Compose invalidates what it needs to.

There is a second reason. The system colour scheme and the host platform are facts the renderer already knows. For the Host to resolve them it would have to ask across the boundary, and the boundary is kept deliberately small.

Seven systems, one widget tree Works

A design system is a pair: a token table (colour, type, shape, spacing) and a set of component rules (elevation, button variants, motion, materials). DesignSystem has seven:

DesignSystemDesign language
Material3Google's Material 3
CupertinoApple's Human Interface Guidelines before macOS 26 and iOS 26
LiquidGlassApple's current language, the one macOS 26 and iOS 26 draw themselves in
FluentMicrosoft's WinUI and Fluent 2
GnomeGNOME's Adwaita
BreezeKDE's Breeze
DeepinDeepin's DDE

The same tree drawn under a different system is meant to look different. Component rules read design-neutral variants. ButtonVariant is Filled, Tonal, Outlined or Text, and each system draws them its own way: Material 3 with a large radius and a ripple, Apple's systems with continuous curvature and a highlight, Fluent at 4 dp with a lighter top border.

Adding a design system does not change the widgets, the property schema or the wire format. It is one variant in a Rust enum, one token table and one set of rules in Kotlin.

Unified and adaptive

The application chooses at launch. Theme and DesignSystem exist in 0.0.1, and these values compile today.

rust, compiles against 0.0.1
use compose_rust::{ColorScheme, DesignSystem, Theme};

// The same design system on every platform.
const UNIFIED: Theme = Theme::unified(DesignSystem::Material3);

// Follow the host platform. The fallback argument is required.
const ADAPTIVE: Theme = Theme::adaptive(DesignSystem::Material3);

// Light and dark are a separate axis. FollowSystem is the default.
const DARK: Theme = UNIFIED.with_color_scheme(ColorScheme::Dark);

A layer that builds the tree passes the theme to LaunchBuilder::with_theme. Not calling it gives Theme::adaptive(DesignSystem::Material3): the platform's own look, with Material 3 where the platform has none. The fallback is a required argument, so there is no platform for which adaptive has nothing to choose.

PlatformWhat adaptive picks
AndroidMaterial 3
macOS, iOSLiquid Glass
WindowsFluent
Linux, GNOME sessionGNOME
Linux, KDE sessionBreeze
Linux, any other session or none detectedDeepin
WebFluent
Anything unrecognisedThe fallback you passed

The Linux rows read XDG_CURRENT_DESKTOP once at startup. The web row is a choice rather than a fact: a browser has no design language of its own, so on the web saying unified outright is the clearer thing to write.

ColorScheme is Light, Dark or FollowSystem. When the system turns dark, the renderer learns first and applies it itself. The Host is not involved, for the same reason that scroll position and focus stay in Kotlin.

Application colours Works

An application with a brand of its own lays a Palette over the design system. It replaces the value of colour roles, one role and one scheme at a time, and nothing else. Shape, type, spacing, elevation, motion and component rules stay the system's. The same orange is a Material 3 button under Material 3 and a glass capsule under Liquid Glass.

rust, compiles against 0.0.1
use compose_rust::{Color, ColorRole, DesignSystem, Palette, Theme};

const EMBER: Palette = Palette::new()
    .with(ColorRole::Primary, Color::rgb(0xE8590C), Color::rgb(0xFF8A4C))
    .with(ColorRole::Background, Color::rgb(0xFFFBF8), Color::rgb(0x141110));

const THEME: Theme = Theme::adaptive(DesignSystem::Material3).with_palette(&EMBER);

with gives light and dark together; with_light and with_dark give one. Both schemes travel at once, so following the system into dark never asks the Host. A role you do not give keeps the system's value. Palette::check(design_system) lists the pairs your palette leaves short of contrast; call it from your own tests for all seven systems. In a platform high-contrast mode the palette is set aside and the system's own colours are drawn.

What crosses the boundary

One SetTheme record, sent as the first record of the initial batch and again only when the application changes the theme. It names the design system, the fallback, the colour scheme and whether the theme is adaptive. A palette travels as a short list of role values. Token tables never cross: they are authored in Rust as data, generated into the renderer at build time, and resolved there.

A cost worth naming

Because the renderer resolves the tokens, a Rust-side test can only assert which role was sent. The actual colours and sizes are verified in renderer tests.

Moving to the Compose fork Planned

The design systems' sources have moved out of this repository into the thisisthepy Compose fork. Splitting them into ordinary Compose libraries under org.thisisthepy.compose.* is still in progress (#39). After that, any Compose application can use them, not only compose-rust.

The roles, the Theme value and the wire record stay as they are. What moves is where the Kotlin that draws them lives.