---
title: "样式与主题 — zenit Zig UI 文档"
description: "用 Token、具名样式函数与 Recipe，让视觉和业务逻辑彼此独立。"
url: https://zenit.z.express/zh/docs/guide/styling
language: zh-CN
alternate_en: https://zenit.z.express/docs/guide/styling.md
alternate_es: https://zenit.z.express/es/docs/guide/styling.md
alternate_ja: https://zenit.z.express/ja/docs/guide/styling.md
alternate_ko: https://zenit.z.express/ko/docs/guide/styling.md
alternate_fr: https://zenit.z.express/fr/docs/guide/styling.md
alternate_de: https://zenit.z.express/de/docs/guide/styling.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 样式与主题

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

## 三层样式模型

**01 · ThemeTokens**

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

**02 · styles.zig**

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

**03 · Recipe**

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

STYLE LAYERS

每一层只读上一层：业务视图从不直接写颜色值，换主题时也就只需换掉最底层的 token。

[Video](https://zenit.z.express/media/stories/button.mp4?v=6203a01415)

Button 的 variant × size、图标、loading、disabled 与 block 矩阵，样式由 recipe 解析。

[Video](https://zenit.z.express/media/stories/glassbox.mp4?v=570227ca3d)

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),
```

> WARNING
> 
> **避免裸字面量。** 业务视图里的 `Color.hex(...)` 与固定 `font_size` 会绕过主题。`scripts/check_style_literals.sh` 只抓裸字面量、放行 `ui.arb`；全局搜索 `ui.arb` 就能盘点所有 off-token 值。同一个 arb 值出现三次以上，就该考虑晋升为 token。

## 具名样式函数

样式函数的签名是 `fn (*const ui.ThemeTokens) ui.BoxStyle`（或 `ui.TextStyle`）。把它们集中在 `styles.zig`，视图只按名字引用。

`styles.zig`

```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`

```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`

```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);
```

> NOTE
> 
> **重放范围是 cx.root 子树。** 组件浮层（Modal、Popover、Menu、Tooltip）挂在 `cx.root` 下的 `WindowOverlayPortal` 里，会随 `setTheme` 一起重放；只有不挂在 `cx.root` 下的独立节点树不在范围内。重放的是 styled 节点注册的 hook——多数 `ui.widgets.*` 在 mount 时快照 token，仍需重建。

明暗差异应当由完整的 token palette 表达；只有在样式函数里确实需要分支时，才读取 `t.scheme`（`.light` / `.dark`）。

想换品牌色、做一套自己的亮 / 暗主题，见 [自定义主题](https://zenit.z.express/zh/docs/guide/theming)。

## 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` 的样式函数。

`examples/hello_button/styles.zig`

```zig
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` 短路，禁用时不再叠加任何其他条件。

`resolve.zig`

```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 / invalid | `ui.ConditionalStyle` |
| 明暗主题 | 完整 token palette；必要时读 `t.scheme` |

## 当前边界

-   **组件多在 mount 时读 token。**`ui.widgets.*` 中没有挂 hook 重读 token 的部分，保留的是挂载时的快照。运行时切换主题后，应用层 styled 节点会重放，组件库节点可能需要重建树。
    
-   **重放只覆盖 cx.root 子树。**窗口浮层 portal 在其中；脱离 `cx.root` 的独立树不会更新。
    
-   **themeSignal 跟随创建它的 Scope。**建议在应用根 Scope 上创建，保证它活得比所有订阅方久。
    

组件的完整 variant 与尺寸矩阵见 [组件站](https://zenit.z.express/zh/components)。
