Design systems
Roles, not pixels Planned
A widget set is not a design. The layer that turns Column and
Button into something that looks like a macOS app, an Android app or a Windows
app is specified in SPEC FR-13 and FR-14. Both are Agreed, down to the wire
tags, and neither is implemented. Nothing on this page exists in the crate yet; it is here so
you can see the shape your code will take and why the seams fall where they do.
As of 2026-09-20 the wire schema carries nine modifiers, Empty,
Padding, FillMaxWidth, FillMaxHeight,
Width, Height, Size, Background and
Clickable, and no theme command. There is no Paint, no
TypeRole, no DesignSystem and no Theme in the Rust
crate. The specification is settled; the code is not written. Every code sample below is
illustrative and will not compile.
Roles first, literals as the escape hatch
The governing rule is that a widget emits a role: "this is a title", "this is the surface colour", "this is medium spacing", and the renderer decides what that role looks like. A literal value is always available too, and always means "draw exactly this".
The reason is not taste, it is the frame budget. The Host runs on the renderer's UI thread,
so any work it does comes straight out of the per-interaction budget. If the Host resolved
tokens, switching to dark mode would re-send a SetProp for every affected
property on every node: O(nodes). With the renderer resolving them, the same switch is one
SetTheme record and Compose invalidates what it needs to through a
CompositionLocal.
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, and the boundary is a one-way synchronous call model with a deliberately frozen surface (PR-2). A query would mean a new entry point.
The primitives (FR-13)
Every value has to fit a fixed-layout record, which gives one modifier variant exactly
(tag: u16, first: u64, second: u64) of room. That constraint is what kept the
list short, and it is why gradients and custom fonts are out.
| Primitive | Values | Resolved by |
|---|---|---|
ColorRole |
14 semantic slots: Primary, OnPrimary, Secondary, OnSecondary, Surface, OnSurface, SurfaceVariant, OnSurfaceVariant, Background, OnBackground, Outline, OutlineVariant, Error, OnError |
The design system, per colour scheme |
Paint |
Paint::Role(ColorRole) or Paint::Literal(Color), packed into one u64 |
Both. Every colour-shaped slot takes a Paint, so colour is described once in the schema rather than twice |
TypeRole |
A nine-rung ladder: Display, Headline, Title, Subtitle, Body, BodyStrong, Label, Caption, Mono |
The design system; individual axes are overridable per Text |
ShapeRole |
None, ExtraSmall, Small, Medium, Large, Full |
The design system, 12 dp on Material 3, continuous curvature on HIG, 4 dp on Fluent |
SpaceRole |
None, Xs, Sm, Md, Lg, Xl, Xxl |
The design system; density is exactly where the three disagree most |
Elevation(dp) |
A single float | The design system. Shadow colour, offset and blur are never sent: Material 3 raises the surface tone, HIG draws a wide soft shadow, Fluent layers a shadow with a hairline stroke |
A Text that carries only type_role takes the system's size, weight,
line height and tracking wholesale. Adding font_size overrides that one axis and
nothing else, each override is an independent SetProp, so the change-only
transport described in the protocol survives.
// Illustrative. Roles are resolved by whichever design system is active.
rsx! {
Column {
padding_role: SpaceRole::Lg,
space_role: SpaceRole::Md,
Text { type_role: TypeRole::Title, text: "Conversations" }
Text {
type_role: TypeRole::Body,
color: Paint::Role(ColorRole::OnSurfaceVariant),
text: "Nothing here yet."
}
Button { variant: ButtonVariant::Filled, text: "New chat" }
}
}
What was left out, and why
| Excluded | Reason |
|---|---|
| Gradients, image brushes | They do not fit in (u64, u64), and a resource would have to cross the boundary. |
| Font families, custom fonts | Fonts live in the renderer bundle. A name sent from the Host defers "does it exist" to runtime. |
| Icon and image widgets | They need an asset protocol, which is a separate requirement rather than a footnote to this one. |
| Animation specs (duration, easing) | Motion is a design-system rule. A Host-supplied curve turns the system into a shell that only receives values. |
| Blur, HIG vibrancy, ripple configuration | Platform-specific effects; not an axis the three systems share. |
| A token table sent by the Host | It would reverse the decision that the renderer resolves tokens. |
Three systems, one widget tree (FR-14)
A design system here is a pair: a token table and a set of component rules. Phase one is
Material 3, Apple HIG and
WinUI/Fluent 2. The same rsx! drawn under a different system
is meant to look different; that is the feature, not a bug to file.
A second phase adds GNOME 50, KDE Breeze and
Deepin for the Linux desktop, and it only starts once phase one draws
correctly. Treat those three as planned: the token tables do not exist, and the
DesignSystem tags are assigned only for the first three.
Component rules attach to variant properties whose names are design-system-neutral.
Button.variant is Filled | Tonal | Outlined | Text, and each system
reads it differently: Material 3 gives its filled, tonal, outlined and text buttons with a
large corner radius and a ripple; HIG gives an accent button with continuous curvature and no
shadow, a grey tonal button, and a plain text button that highlights instead of rippling;
Fluent gives accent, standard, standard-with-stroke and subtle at 4 dp with a lighter
top border.
The payoff is FR-14's acceptance criterion: adding a fourth design system must not change
widgets.rs, the property schema or the wire format. One variant in a Rust enum,
one token table and one rule implementation in Kotlin.
Unified and adaptive
The application chooses explicitly, at launch.
Theme does not exist in the crate yet// The same design system on every platform.
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);
adaptive is not the default. An app that never calls
with_theme gets Theme::unified(DesignSystem::Material3), because a
default that quietly looks different on every platform is a surprise rather than a
convenience. And because the fallback argument is required, adaptive has no platform it
cannot answer for.
| Platform | What adaptive picks |
|---|---|
| Android | Material 3 |
| macOS, iOS | Apple HIG |
| Windows | WinUI/Fluent 2 |
| Linux (GNOME) | GNOME 50 Planned |
| Linux (KDE) | KDE Breeze Planned |
| Linux (anything else, or undetectable) | Deepin Planned |
| Web | WinUI/Fluent 2, switchable to Material 3 by configuration |
The Linux rows read the desktop environment once at startup: XDG_CURRENT_DESKTOP
first, then DESKTOP_SESSION if that is empty. A value containing
GNOME selects GNOME 50, KDE selects Breeze, and anything else, or a
failure to tell, selects Deepin. Until those phase-two tables exist, Linux uses the fallback
you passed.
The web row is a choice rather than a fact. A browser has no design language of its own, so
adaptive has nothing to adapt to; the default is Fluent 2 and an app can
configure Material 3 instead. On the web, saying unified outright is the clearer
thing to write.
Light and dark are a separate axis from the design system: ColorScheme is
Light | Dark | FollowSystem and defaults to FollowSystem. The
renderer learns of a system change first and applies it itself. The Host is not involved, the same rule that keeps scroll position and focus on the Kotlin side.
What it adds to the wire
One mutation: SetTheme { design_system, fallback, color_scheme, adaptive },
command tag 9, a 12-byte record of four u16 fields after tag
and len, applied to the root rather than to a node. The Host sends it once as the
first record of the initial batch, and again only when the application changes the theme. The
design system tags are Material3 = 1, AppleHig = 2,
Fluent = 3; the colour scheme tags are Light = 1,
Dark = 2, FollowSystem = 3.
The primitives are tagged too, and the tags are fixed: every role enum starts at 1, because 0
means "not sent" and can never be a role. The new modifiers append to the existing list as
PaddingRole = 9, PaddingEach = 10, Weight = 11,
Shape = 12, ShapeRole = 13, Border = 14 and
Elevation = 15, and the new text and layout properties append after
OnRangeRequested as TypeRole = 13 through
Variant = 26. One existing field changes shape rather than tag:
Modifier::Background (tag 7) carries a Paint as a u64
instead of a raw argb: u32, so that colour has exactly one representation in the
schema. The record length is unchanged and only the schema hash moves.
The token tables themselves are authored in Rust and executed in the renderer. The colour,
type, shape and spacing values live in the Rust schema as data, and codegen emits them into
Protocol.gen.kt; they are baked into the renderer binary at build time and never
cross the boundary at runtime. A renderer implementer fills in the rules, not the numbers.
Implementing a design system is then seven tables: the 14 colour roles in both schemes, the 9 type roles, the 6 shape roles, the 7 spacing roles, the elevation rendering rule, the 4 button variants, and the motion durations and easing. The first four are generated; the last three are the renderer's own work. Once those are filled in, no widget code changes.
Because the renderer resolves the tokens, a Rust-side unit test can only assert which role was sent. The actual colours and dimensions are verified in renderer tests. The specification accepts that split rather than pretending it does not exist.