docs/guide/styling
核心概念 · Design system

样式与主题

用 Token、具名样式函数与 Recipe,让视觉和业务逻辑彼此独立。

预计阅读 12 分钟

三层样式模型

01
01 · ThemeTokens

颜色、字号、间距、圆角与控件尺度的唯一来源。

02
02 · styles.zig

可命名、复用、测试,并在换主题时重放的纯函数。

03
03 · Recipe

处理变体、交互条件态与多部件组件。

STYLE LAYERS
每一层只读上一层:业务视图从不直接写颜色值,换主题时也就只需换掉最底层的 token。
Button 的 variant × size、图标、loading、disabled 与 block 矩阵,样式由 recipe 解析。
GlassBox 的 regular、interactive、clear 与真实 backdrop blur。

Token 优先

样式值从 cx.tokens(或样式函数收到的 t)读取。确实不在刻度上的设计值,用 ui.arb 显式标出:它在运行时是零开销的恒等函数,价值在于语义——读者一眼能区分「有意的任意值」与「该迁到 token 的偷懒字面量」。

zig
// 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,视图只按名字引用。

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,
    };
}
view.zig
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)。

theme.zig
// 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 最后应用。

RECIPE MERGE
每一步只覆盖自己写了的字段。derived 按约定只产出几何字段(padding、圆角、尺寸),不碰 background。
Config 成员作用
Variants必需。每个字段是一个变体维度(enum 或 bool),带默认值
base(t)可选。基础 ConditionalStyle
variants可选。每个维度一个 resolver:fn (value, t) ConditionalStyle
derived(v, t)可选。拿到完整 Variants,表达跨维度的连续组合(如 padding = f(size, icon 模式))
compounds可选。{ matches(v), style(t) },多个维度同时满足时叠加

下面是仓库里 examples/hello_button/styles.zig 的真实写法:应用层同样可以定义自己的 recipe,再用 resolveBase 固化成可交给 ui.boxStyled 的样式函数。

ConditionalStyle 在 base 之外还带七个条件位:selected、expanded、hover、active、focus、invalid、disabled。resolve(state) 依次叠加 selected → expanded → hover → active → focus → invalid;disabled 短路,禁用时不再叠加任何其他条件。

resolve.zig
// 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.recipe
输入框、菜单等多部件组件ui.recipe.slotRecipe
hover / disabled / invalidui.ConditionalStyle
明暗主题完整 token palette;必要时读 t.scheme

当前边界

  • ✓

    组件多在 mount 时读 token。ui.widgets.* 中没有挂 hook 重读 token 的部分,保留的是挂载时的快照。运行时切换主题后,应用层 styled 节点会重放,组件库节点可能需要重建树。

  • ✓

    重放只覆盖 cx.root 子树。窗口浮层 portal 在其中;脱离 cx.root 的独立树不会更新。

  • ✓

    themeSignal 跟随创建它的 Scope。建议在应用根 Scope 上创建,保证它活得比所有订阅方久。

组件的完整 variant 与尺寸矩阵见 组件站。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30