样式与主题
用 Token、具名样式函数与 Recipe,让视觉和业务逻辑彼此独立。
三层样式模型
颜色、字号、间距、圆角与控件尺度的唯一来源。
可命名、复用、测试,并在换主题时重放的纯函数。
处理变体、交互条件态与多部件组件。
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 并标脏;需要响应式地读当前主题时用 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)。
想换品牌色、做一套自己的亮 / 暗主题,见 自定义主题。
Recipe 与条件态
ui.recipe 是一个命名空间:ui.recipe.recipe(Config) 定义单节点配方,ui.recipe.slotRecipe(Config) 定义多部件配方。解析时按 base → variants → derived → compounds 合并,后写入的非空字段覆盖先前的值;组件的外部 style override 最后应用。
Variants必需。每个字段是一个变体维度(enum 或 bool),带默认值base(t)可选。基础 ConditionalStylevariants可选。每个维度一个 resolver:fn (value, t) ConditionalStylederived(v, t)可选。拿到完整 Variants,表达跨维度的连续组合(如 padding = f(size, icon 模式))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当前边界
- ✓
组件多在 mount 时读 token。
ui.widgets.*中没有挂 hook 重读 token 的部分,保留的是挂载时的快照。运行时切换主题后,应用层 styled 节点会重放,组件库节点可能需要重建树。 - ✓
重放只覆盖 cx.root 子树。窗口浮层 portal 在其中;脱离
cx.root的独立树不会更新。 - ✓
themeSignal 跟随创建它的 Scope。建议在应用根 Scope 上创建,保证它活得比所有订阅方久。
组件的完整 variant 与尺寸矩阵见 组件站。