样式与主题Styling & themes
用 Token、具名样式函数与 Recipe,让视觉和业务逻辑彼此独立。Use tokens, named style functions and recipes so visuals and business logic stay independent.
三层样式模型The three-layer model
颜色、字号、间距、圆角与控件尺度的唯一来源。The single source for colors, type sizes, spacing, radii and control metrics.
可命名、复用、测试,并在换主题时重放的纯函数。Pure functions you can name, reuse, test — and replay when the theme changes.
处理变体、交互条件态与多部件组件。Handles variants, interaction conditions and multi-part components.
Token 优先Tokens first
样式值从 cx.tokens(或样式函数收到的 t)读取。确实不在刻度上的设计值,用 ui.arb 显式标出:它在运行时是零开销的恒等函数,价值在于语义——读者一眼能区分「有意的任意值」与「该迁到 token 的偷懒字面量」。Read style values from cx.tokens (or the t a style function receives). For design values that genuinely sit off the scale, mark them with ui.arb: it is a zero-cost identity at runtime, and its value is semantic — readers can tell an intentional arbitrary value from a lazy literal that should become a 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),具名样式函数Named style functions
样式函数的签名是 fn (*const ui.ThemeTokens) ui.BoxStyle(或 ui.TextStyle)。把它们集中在 styles.zig,视图只按名字引用。A style function has the signature fn (*const ui.ThemeTokens) ui.BoxStyle (or ui.TextStyle). Keep them together in styles.zig and let views refer to them by name.
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 只是挂载时的快照。boxStyled, textStyled, hstackStyled and vstackStyled register an on_theme hook on the node that remembers the style function itself. When the theme changes, the node recomputes its style from the new tokens; tokens read inside a plain ui.box are just a snapshot from mount time.
切换主题Switching themes
内置三套主题:ui.theme.light、ui.theme.dark、ui.theme.high_contrast。cx.setTheme 替换 token 指针,遍历 cx.root 子树重放每个节点的 on_theme 与 before_render hook 并标脏;需要响应式地读当前主题时用 cx.themeSignal(scope)。Three themes ship built in: ui.theme.light, ui.theme.dark and ui.theme.high_contrast. cx.setTheme swaps the token pointer, walks the cx.root subtree replaying every node’s on_theme and before_render hooks, and marks them dirty. For reactive access to the current theme, use 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 palette 表达;只有在样式函数里确实需要分支时,才读取 t.scheme(.light / .dark)。Light/dark differences belong in the full token palette; read t.scheme (.light / .dark) inside a style function only when you truly need a branch.
想换品牌色、做一套自己的亮 / 暗主题,见 自定义主题。To apply your brand color or build your own light / dark themes, see Custom themes.
Recipe 与条件态Recipes & conditions
ui.recipe 是一个命名空间:ui.recipe.recipe(Config) 定义单节点配方,ui.recipe.slotRecipe(Config) 定义多部件配方。解析时按 base → variants → derived → compounds 合并,后写入的非空字段覆盖先前的值;组件的外部 style override 最后应用。ui.recipe is a namespace: ui.recipe.recipe(Config) defines a single-node recipe and ui.recipe.slotRecipe(Config) a multi-part one. Resolution merges base → variants → derived → compounds, with later non-null fields winning; a component’s external style override is applied last.
Variants必需。每个字段是一个变体维度(enum 或 bool),带默认值Required. Each field is a variant dimension (enum or bool) with a defaultbase(t)可选。基础 ConditionalStyleOptional. The base ConditionalStylevariants可选。每个维度一个 resolver:fn (value, t) ConditionalStyleOptional. One resolver per dimension: fn (value, t) ConditionalStylederived(v, t)可选。拿到完整 Variants,表达跨维度的连续组合(如 padding = f(size, icon 模式))Optional. Sees all Variants, for cross-dimension continuous logic (e.g. padding = f(size, icon mode))compounds可选。{ matches(v), style(t) },多个维度同时满足时叠加Optional. { matches(v), style(t) } applied when several dimensions match together下面是仓库里 examples/hello_button/styles.zig 的真实写法:应用层同样可以定义自己的 recipe,再用 resolveBase 固化成可交给 ui.boxStyled 的样式函数。Here is the real code from examples/hello_button/styles.zig: apps can define their own recipes too, then freeze a variant with resolveBase into a style function that ui.boxStyled accepts.
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 短路,禁用时不再叠加任何其他条件。Besides base, a ConditionalStyle carries seven condition slots: selected, expanded, hover, active, focus, invalid, disabled. resolve(state) layers selected → expanded → hover → active → focus → invalid in that order; disabled short-circuits, so nothing else stacks on a disabled node.
// 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.schemeA full token palette; read t.scheme if you must当前边界Current boundaries
- ✓
组件多在 mount 时读 token。
ui.widgets.*中没有挂 hook 重读 token 的部分,保留的是挂载时的快照。运行时切换主题后,应用层 styled 节点会重放,组件库节点可能需要重建树。Components mostly read tokens at mount. Parts ofui.widgets.*that don’t register a hook to re-read tokens keep their mount-time snapshot. After a runtime theme switch, app-level styled nodes replay, but component-library nodes may need the tree rebuilt. - ✓
重放只覆盖 cx.root 子树。窗口浮层 portal 在其中;脱离
cx.root的独立树不会更新。Replay covers the cx.root subtree. The window overlay portal is inside it; trees detached fromcx.rootare not updated. - ✓
themeSignal 跟随创建它的 Scope。建议在应用根 Scope 上创建,保证它活得比所有订阅方久。themeSignal follows the Scope that created it. Create it on the app’s root Scope so it outlives every subscriber.
组件的完整 variant 与尺寸矩阵见 组件站。See the component site for every component’s variant and size matrix.