dioxus-compose 가이드

디자인 시스템

픽셀이 아니라 역할 계획

위젯 집합만으로는 디자인이 되지 않습니다. ColumnButton을 macOS다운, Android다운, Windows다운 모습으로 만드는 층은 SPEC FR-13과 FR-14에 적혀 있습니다. 둘 다 와이어 태그까지 Agreed이지만 구현은 아직 없습니다. 이 페이지의 내용은 크레이트에 하나도 없습니다. 앞으로 코드가 어떤 모양이 될지, 그리고 경계선을 왜 그 자리에 그었는지 보여 주려고 먼저 써 둡니다.

여기 있는 것은 아직 구현되지 않았습니다

2026-09-20 기준으로 와이어 스키마에 있는 Modifier는 Empty, Padding, FillMaxWidth, FillMaxHeight, Width, Height, Size, Background, Clickable 아홉 개뿐이고 테마 명령은 없습니다. Rust 크레이트에 PaintTypeRoleDesignSystemTheme도 없습니다. 명세는 확정되었고 코드가 아직 없는 상태입니다. 아래 코드는 전부 예시이며 컴파일되지 않습니다.

역할을 먼저, 리터럴은 탈출구로

기본 규칙은 이렇습니다. 위젯은 역할만 내보냅니다. "이건 제목", "이건 표면 색", "이건 중간 간격" 정도입니다. 그 역할이 실제로 어떻게 보일지는 Renderer가 정합니다. 리터럴 값도 언제든 쓸 수 있고, 리터럴은 "정확히 이대로 그려라"라는 뜻입니다.

취향의 문제가 아니라 프레임 예산의 문제입니다. Host는 Renderer의 UI 스레드에서 돌기 때문에 Host가 하는 일은 그대로 상호작용당 예산에서 빠집니다. Host가 토큰을 푼다면 다크 모드 전환 한 번이 모든 노드의 관련 속성마다 SetProp을 다시 보내는 일(O(노드 수))이 됩니다. Renderer가 풀면 같은 전환이 SetTheme 한 건이고, 나머지는 Compose가 CompositionLocal 무효화로 처리합니다.

이유가 하나 더 있습니다. 시스템 명암 설정과 호스트 플랫폼은 Renderer가 이미 아는 사실입니다. Host가 이를 해석하려면 물어봐야 하는데, 경계는 의도적으로 고정해 둔 단방향 동기 호출 모델입니다(PR-2). 질의를 넣는 순간 진입점이 하나 늘어납니다.

프리미티브 (FR-13)

모든 값은 고정 레이아웃 레코드에 들어가야 하고, Modifier 한 변형에 허용된 공간은 (tag: u16, first: u64, second: u64)뿐입니다. 목록이 짧은 이유이자 그라데이션과 커스텀 폰트가 빠진 이유입니다.

프리미티브해석 주체
ColorRole 의미 슬롯 14개: Primary, OnPrimary, Secondary, OnSecondary, Surface, OnSurface, SurfaceVariant, OnSurfaceVariant, Background, OnBackground, Outline, OutlineVariant, Error, OnError 디자인 시스템이 명암별로
Paint Paint::Role(ColorRole) 또는 Paint::Literal(Color). u64 하나에 담습니다 둘 다. 색이 들어가는 자리는 전부 Paint라서 색 표현이 스키마에 두 번 등장하지 않습니다
TypeRole 9단 사다리: Display, Headline, Title, Subtitle, Body, BodyStrong, Label, Caption, Mono 디자인 시스템. 축별 재정의는 Text 속성으로
ShapeRole None, ExtraSmall, Small, Medium, Large, Full 디자인 시스템. Material 3의 12 dp, HIG의 연속 곡률, Fluent의 4 dp가 여기서 갈립니다
SpaceRole None, Xs, Sm, Md, Lg, Xl, Xxl 디자인 시스템. 밀도야말로 세 시스템이 가장 크게 다른 부분입니다
Elevation(dp) float 하나 디자인 시스템. 그림자 색·오프셋·블러는 보내지 않습니다. Material 3는 톤을 올리고, HIG는 넓고 옅은 그림자를, Fluent는 층 그림자에 가는 스트로크를 씁니다

type_role만 지정한 Text는 디자인 시스템이 정한 크기·굵기·행간·자간을 그대로 씁니다. font_size를 함께 주면 그 축만 덮어씁니다. 재정의 하나하나가 독립된 SetProp이라서 변경분만 보내는 전송이 그대로 유지됩니다.

예시: FR-13은 명세만 있고 구현이 없어 이 코드는 컴파일되지 않습니다
// 예시. 역할은 그때 활성화된 디자인 시스템이 해석합니다.
rsx! {
    Column {
        padding_role: SpaceRole::Lg,
        space_role: SpaceRole::Md,
        Text { type_role: TypeRole::Title, text: "대화" }
        Text {
            type_role: TypeRole::Body,
            color: Paint::Role(ColorRole::OnSurfaceVariant),
            text: "아직 아무것도 없습니다."
        }
        Button { variant: ButtonVariant::Filled, text: "새 대화" }
    }
}

넣지 않은 것과 그 이유

제외이유
그라데이션, 이미지 브러시(u64, u64)에 들어가지 않고, 리소스가 경계를 넘어야 합니다.
폰트 패밀리, 커스텀 폰트폰트는 Renderer 번들에 있습니다. Host가 이름만 보내면 "그 폰트가 있는가"가 런타임 문제로 밀립니다.
아이콘·이미지 위젯에셋 전달 프로토콜이 따로 필요합니다. 이 요구사항의 각주가 아니라 별도 요구사항입니다.
애니메이션 스펙(duration, easing)모션은 디자인 시스템의 규칙입니다. Host가 곡선을 주면 시스템은 값만 받는 껍데기가 됩니다.
블러, HIG vibrancy, 리플 설정플랫폼 전용 효과라 세 시스템의 공통 축이 아닙니다.
Host가 보내는 토큰 테이블토큰 해석을 Renderer가 한다는 결정을 정면으로 뒤집습니다.

세 개의 시스템, 하나의 위젯 트리 (FR-14)

여기서 디자인 시스템은 토큰 테이블 + 컴포넌트 규칙 한 쌍입니다. 1단계는 Material 3, Apple HIG, WinUI/Fluent 2입니다. 같은 rsx!가 시스템에 따라 다르게 보이는 것은 정상 동작입니다. 버그로 접수할 일이 아닙니다.

2단계로 Linux 데스크톱용 GNOME 50, KDE Breeze, Deepin을 더하지만, 1단계가 제대로 그려진 다음의 일입니다. 이 셋은 계획으로 보시면 됩니다. 토큰 테이블이 없고 DesignSystem 태그도 앞의 셋에만 배정되어 있습니다.

컴포넌트 규칙이 붙는 자리는 변형(variant) 속성이고, 이름은 특정 시스템에 치우치지 않게 짓습니다. Button.variantFilled | Tonal | Outlined | Text이고 시스템마다 다르게 읽습니다. Material 3는 큰 곡률과 리플이 있는 Filled/Tonal/Outlined/Text 버튼, HIG는 연속 곡률에 그림자가 없는 강조 버튼과 회색 배경의 Tonal, 리플 대신 하이라이트가 뜨는 plain 버튼, Fluent는 4 dp 곡률에 위쪽 테두리가 밝은 Accent/Standard/Standard+stroke/Subtle입니다.

그 대가로 얻는 것이 FR-14의 수용 기준입니다. 네 번째 디자인 시스템을 더해도 widgets.rs와 속성 스키마, 와이어 포맷은 그대로여야 합니다. Rust enum에 변형 하나, Kotlin에 테이블 하나와 규칙 구현 하나면 끝입니다.

unified와 adaptive

애플리케이션이 실행 시점에 명시적으로 고릅니다.

예시: Theme은 크레이트에 아직 없습니다
// 모든 플랫폼에서 같은 디자인 시스템.
LaunchBuilder::new()
    .with_theme(Theme::unified(DesignSystem::Material3))
    .launch(app);

// 호스트 플랫폼을 따라갑니다. fallback 인자는 필수입니다.
LaunchBuilder::new()
    .with_theme(Theme::adaptive(DesignSystem::Material3))
    .launch(app);

adaptive기본값이 아닙니다. with_theme을 부르지 않은 앱은 Theme::unified(DesignSystem::Material3)입니다. 기본값이 플랫폼마다 조용히 다른 모습이 되는 것은 편의가 아니라 놀라움이기 때문입니다. 그리고 fallback 인자가 필수라서 adaptive에는 답을 못 하는 플랫폼이 남지 않습니다.

플랫폼adaptive가 고르는 시스템
AndroidMaterial 3
macOS, iOSApple HIG
WindowsWinUI/Fluent 2
Linux (GNOME)GNOME 50 계획
Linux (KDE)KDE Breeze 계획
Linux (그 외, 판별 불가)Deepin 계획
WebWinUI/Fluent 2 (설정으로 Material 3 교체 가능)

Linux 행은 시작할 때 데스크톱 환경을 한 번만 읽어서 정합니다. XDG_CURRENT_DESKTOP을 먼저 보고, 비어 있으면 DESKTOP_SESSION을 봅니다. 값에 GNOME이 들어 있으면 GNOME 50, KDE면 Breeze, 그 외와 판별 실패는 Deepin입니다. 2단계 테이블이 생기기 전까지 Linux는 인자로 준 fallback을 씁니다.

Web 행은 사실이라기보다 선택입니다. 브라우저에는 자기 디자인 언어가 없어서 adaptive가 맞출 대상이 없습니다. 기본은 Fluent 2이고 앱이 설정으로 Material 3를 고를 수 있습니다. 문서를 쓰는 입장에서는 Web에 unified를 명시하는 편이 분명합니다.

명암은 디자인 시스템과 별개의 축입니다. ColorSchemeLight | Dark | FollowSystem이고 기본은 FollowSystem입니다. 시스템 설정이 바뀌면 Renderer가 먼저 알고 스스로 반영합니다. Host는 관여하지 않습니다. 스크롤 위치와 포커스를 Kotlin 쪽에 두는 것과 같은 규칙입니다.

와이어에 더해지는 것

Mutation 하나입니다. SetTheme { design_system, fallback, color_scheme, adaptive }, 명령 태그 9, taglen 뒤에 u16 네 개가 오는 12바이트 레코드이고, 특정 노드가 아니라 루트에 적용합니다. Host는 최초 배치의 첫 레코드로 한 번 보내고, 이후에는 앱이 테마를 바꿀 때만 다시 보냅니다. 디자인 시스템 태그는 Material3 = 1, AppleHig = 2, Fluent = 3이고, 명암 태그는 Light = 1, Dark = 2, FollowSystem = 3입니다.

프리미티브에도 고정 태그가 배정되어 있습니다. 역할 enum은 모두 1부터 시작합니다. 0은 "보내지 않음"이라서 역할 값으로 쓸 수 없기 때문입니다. 새 Modifier는 기존 목록 뒤에 PaddingRole = 9, PaddingEach = 10, Weight = 11, Shape = 12, ShapeRole = 13, Border = 14, Elevation = 15로 붙고, 새 텍스트·레이아웃 속성은 OnRangeRequested 뒤에 TypeRole = 13부터 Variant = 26까지 붙습니다. 태그 대신 모양이 바뀌는 것이 하나 있습니다. Modifier::Background(태그 7)는 argb: u32 대신 Paintu64로 싣습니다. 색 표현을 스키마에 두 번 두지 않기 위해서입니다. 레코드 길이는 그대로이고 스키마 해시만 바뀝니다.

토큰 테이블의 저작 위치는 Rust이고 실행 위치는 Renderer입니다. 색·타입·모양·간격 값은 Rust 스키마에 데이터로 두고, 코드젠이 Protocol.gen.kt로 내보냅니다. 표는 빌드 시점에 Renderer 바이너리로 들어가며 런타임에 경계를 넘지 않습니다. Renderer 구현자가 채우는 것은 값이 아니라 규칙입니다.

디자인 시스템 하나를 구현한다는 것은 표 일곱 개를 채우는 일입니다. 명암 두 벌의 색 역할 14개, 타입 역할 9개, 모양 역할 6개, 간격 역할 7개, elevation 렌더링 규칙, 버튼 변형 4개, 그리고 모션의 duration과 easing. 앞의 넷은 코드젠이 만들고, 뒤의 셋이 Renderer의 몫입니다. 이것들이 채워지면 위젯 코드는 건드릴 일이 없습니다.

짚고 갈 비용

토큰을 Renderer가 풀기 때문에 Rust 쪽 단위 테스트가 검증할 수 있는 것은 어떤 역할을 보냈는가까지입니다. 실제 색과 치수는 Renderer 테스트에서 확인합니다. 스펙은 이 분리를 없는 척하지 않고 받아들입니다.