스타일과 테마
Token, 이름 있는 스타일 함수, Recipe를 사용해 시각 요소와 비즈니스 로직을 서로 독립적으로 유지합니다.
3계층 스타일 모델
색상, 글자 크기, 간격, 모서리 반경, 컨트롤 치수의 단일 출처.
이름을 붙이고, 재사용하고, 테스트할 수 있으며 테마가 바뀌면 다시 실행되는 순수 함수.
변형, 상호작용 조건 상태, 여러 파트로 된 컴포넌트를 처리합니다.
Token 우선
스타일 값은 cx.tokens(또는 스타일 함수가 받는 t)에서 읽습니다. 정말로 스케일에서 벗어난 디자인 값은 ui.arb로 명시합니다. 런타임에는 비용이 없는 항등 함수이며, 가치는 의미에 있습니다. 읽는 사람이 ‘의도된 임의 값’과 ‘token으로 옮겨야 할 대충 쓴 리터럴’을 한눈에 구분할 수 있습니다.
// Preferred: follows the theme
.background = cx.tokens.color.bg_primary,
.padding = ui.Padding.all(cx.tokens.space._4),
.font_size = cx.tokens.font_size.xl,
// Legitimate escape hatch: states that the value is intentionally off-scale
.font_size = ui.arb.px(18),
.background = ui.arb.hex(0x316ff6),
.border_color = ui.arb.hexA(0x000000, 0.12),이름 있는 스타일 함수
스타일 함수의 시그니처는 fn (*const ui.ThemeTokens) ui.BoxStyle(또는 ui.TextStyle)입니다. styles.zig에 모아 두고 뷰에서는 이름으로 참조합니다.
const ui = @import("ui");
pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
return .{
.background = t.color.bg_secondary,
.padding = ui.Padding.all(t.space._6),
.gap = t.space._3,
};
}
pub fn row(t: *const ui.ThemeTokens) ui.BoxStyle {
return .{ .gap = t.space._2 };
}
pub fn title(t: *const ui.ThemeTokens) ui.TextStyle {
return .{
.font_size = t.font_size.xxl,
.font_weight = 600,
.color = t.color.fg_primary,
};
}const S = @import("styles.zig");
const card = try ui.boxStyled(cx, S.card, .{});
try card.appendChild(
cx.allocator,
try ui.textStyled(cx, S.title, "Workspace"),
);
// hstackStyled / vstackStyled force direction = .row / .column.
const actions = try ui.hstackStyled(cx, S.row, .{});
try card.appendChild(cx.allocator, actions);boxStyled, textStyled, hstackStyled, vstackStyled는 노드에 on_theme hook을 등록해 스타일 함수 자체를 기억합니다. 테마가 바뀌면 노드는 새 token으로 스타일을 다시 계산합니다. 일반 ui.box 안에서 읽은 token은 마운트 시점의 스냅숏일 뿐입니다.
테마 전환
기본 제공 테마는 ui.theme.light, ui.theme.dark, ui.theme.high_contrast 세 가지입니다. cx.setTheme는 token 포인터를 교체하고, cx.root 서브트리를 순회하며 각 노드의 on_theme와 before_render hook을 다시 실행한 뒤 dirty로 표시합니다. 현재 테마를 반응형으로 읽으려면 cx.themeSignal(scope)를 사용합니다.
// Built-in palettes: ui.theme.light, ui.theme.dark, ui.theme.high_contrast
cx.setTheme(&ui.theme.dark);
// Reactive access: a Signal that setTheme updates.
const theme = try cx.themeSignal(scope);
try scope.createEffect(.{ .theme = theme }, struct {
fn run(ctx: anytype) void {
const t = ctx.theme.get();
std.log.info("theme: {s} ({s})", .{ t.name, @tagName(t.scheme) });
}
}.run);라이트/다크 차이는 완전한 token 팔레트로 표현해야 합니다. 스타일 함수 안에서 정말로 분기가 필요할 때만 t.scheme(.light / .dark)를 읽습니다.
브랜드 색상을 적용하거나 자신만의 라이트 / 다크 테마를 만들려면 커스텀 테마를 참고하십시오.
Recipe와 조건 상태
ui.recipe는 네임스페이스입니다. ui.recipe.recipe(Config)는 단일 노드 recipe를, ui.recipe.slotRecipe(Config)는 여러 파트로 된 recipe를 정의합니다. 해석 시 base → variants → derived → compounds 순으로 병합되며, 나중에 쓴 null이 아닌 필드가 우선합니다. 컴포넌트의 외부 style override는 마지막에 적용됩니다.
Variants필수. 각 필드는 기본값을 가진 변형 차원(enum 또는 bool)base(t)선택. 기본 ConditionalStylevariants선택. 차원마다 resolver 하나: fn (value, t) ConditionalStylederived(v, t)선택. 전체 Variants를 받아 차원을 넘나드는 연속적 로직을 표현(예: padding = f(size, 아이콘 모드))compounds선택. { matches(v), style(t) }. 여러 차원이 동시에 일치할 때 겹쳐 적용다음은 저장소의 examples/hello_button/styles.zig에 있는 실제 코드입니다. 앱에서도 자체 recipe를 정의한 뒤, resolveBase로 변형을 고정해 ui.boxStyled가 받는 스타일 함수로 만들 수 있습니다.
pub const PanelEmphasis = enum { neutral, highlight };
/// CVA-style: base + one variant dimension, all from tokens.
const PanelRecipe = ui.recipe.recipe(struct {
pub const Variants = struct {
emphasis: PanelEmphasis = .neutral,
};
pub fn base(t: *const ui.ThemeTokens) ui.ConditionalStyle {
return .{ .base = .{
.padding = ui.Padding.symmetric(t.space._2, t.space._4),
.corner_radius = t.radius.md,
} };
}
pub const variants = .{
.emphasis = struct {
fn resolve(e: PanelEmphasis, t: *const ui.ThemeTokens) ui.ConditionalStyle {
return switch (e) {
.neutral => .{ .base = .{ .background = t.color.bg_secondary } },
.highlight => .{ .base = .{
.background = t.color.bg_tertiary,
.border = .{ .width = 1, .color = t.color.accent, .radius = 0 },
} },
};
}
}.resolve,
};
});
/// Freeze a variant into a theme-safe style function for ui.boxStyled.
pub fn panel(comptime emphasis: PanelEmphasis) fn (*const ui.ThemeTokens) ui.BoxStyle {
return struct {
fn f(t: *const ui.ThemeTokens) ui.BoxStyle {
return PanelRecipe.resolveBase(.{ .emphasis = emphasis }, t);
}
}.f;
}ConditionalStyle은 base 외에 일곱 개의 조건 슬롯을 가집니다: selected, expanded, hover, active, focus, invalid, disabled. resolve(state)는 selected → expanded → hover → active → focus → invalid 순서로 겹칩니다. disabled는 단락되므로 비활성화된 노드에는 다른 조건이 겹치지 않습니다.
// Full ConditionalStyle for one variant combination.
const cs = PanelRecipe.resolve(.{ .emphasis = .highlight }, cx.tokens);
// A component's style props are applied last, on top of the recipe output.
const merged = cs.override(.{
.style = .{ .background = cx.tokens.color.accent },
});
// Pick the final style for the current interaction state.
const final: ui.BoxStyle = merged.resolve(.{ .is_hovered = true });ui.recipe.recipeui.recipe.slotRecipeui.ConditionalStylet.scheme를 읽음현재의 한계
- ✓
컴포넌트는 대부분 마운트 시 token을 읽습니다.
ui.widgets.*중 token을 다시 읽는 hook을 등록하지 않은 부분은 마운트 시점의 스냅숏을 유지합니다. 런타임에 테마를 전환하면 앱 레벨의 styled 노드는 다시 실행되지만, 컴포넌트 라이브러리 노드는 트리를 재구성해야 할 수 있습니다. - ✓
재실행은 cx.root 서브트리만 대상입니다. 창 오버레이 portal은 그 안에 있습니다.
cx.root에서 분리된 트리는 갱신되지 않습니다. - ✓
themeSignal은 자신을 만든 Scope를 따릅니다. 모든 구독자보다 오래 살아 있도록 앱의 루트 Scope에서 만드십시오.
각 컴포넌트의 variant와 크기 매트릭스는 컴포넌트 사이트에서 확인하십시오.