自定义主题
主题就是一个 ThemeTokens 值:颜色、间距、圆角、字号和控件尺度都在里面。从内置预设复制一份、改掉品牌相关的字段,再用 cx.setTheme 装上,就是你自己的主题。
这一页讲怎么定义和装配主题。样式函数、recipe 这些「怎么消费 token」的内容在 样式与主题。
默认主题与选择时机
每个窗口的 Cx 一开始指向 ui.theme.dark。不调用 setTheme 的应用就是暗色的——Hello Button 就是这样。要用别的主题,在挂载函数的第一行设置,赶在任何节点创建之前:
fn mountUI(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
// Cx starts on ui.theme.dark. Pick the theme before building any node,
// so every component reads the right tokens when it mounts.
cx.setTheme(&ui.theme.light);
const root = try ui.boxStyled(cx, S.root, .{});
// …
return root;
}多窗口时每个窗口有自己的 Cx,要在各自的挂载函数里设置。zenit 目前不会自动跟随系统外观:系统切换明暗只会触发一次重绘,也没有读取系统外观的公开 API。需要时给用户一个切换开关(见下文「运行时切换」)。
ThemeTokens 的结构
pub const ThemeTokens = struct {
name: []const u8 = "unnamed",
scheme: ColorScheme = .light, // .light / .dark — the one flag for "is this dark?"
color: ColorTokens, // 54 colors, no defaults: you must provide all of them
space: SpaceScale = .{}, // _0 … _12 (0 … 48 px, 4 px grid)
radius: RadiusScale = .{}, // none sm md lg xl full
font_size: FontSizeScale = .{}, // xxs … xxxl (9 … 24)
border_width: BorderWidthScale = .{},
size: SizeTokens = .{}, // checkbox, switch, badge, dot, close button
control: ControlScale = .{}, // xs / sm / md / lg metrics for every control
duration: DurationTokens = .{}, // fast / normal / slow (seconds)
shadow: ShadowTokens = .{}, // sm / md / lg
};除了 color,其余字段都有默认值。ColorTokens 的 54 个颜色没有默认值,所以不要从零写一个 struct 字面量——从 ui.theme.light、ui.theme.dark 或 ui.theme.high_contrast 复制,只改你关心的字段。
从预设派生品牌主题
把主题写成容器级 const,初始化块在编译期求值;这样得到的是静态存储的值,cx.setTheme(&brand) 保存的指针在整个程序运行期间都有效。不要把主题放在函数的局部变量里再取地址。
// theme.zig — your app's themes. Container-level consts: they are
// evaluated at compile time and live for the whole program, which is
// what cx.setTheme(&brand) needs (it keeps the pointer).
const ui = @import("ui");
pub const brand: ui.ThemeTokens = blk: {
var t = ui.theme.light; // copy a complete preset (every color is required)
t.name = "brand";
t.scheme = .light;
const blue = ui.Color.hex(0x1144AA);
t.color.accent = blue; // links, checked states, active tabs…
t.color.accent_hover = ui.Color.hex(0x0D3688);
t.color.accent_subtle = ui.Color.hex(0xE6ECF7);
t.color.button_primary_bg = blue; // primary buttons read these two,
t.color.button_primary_fg = ui.Color.WHITE; // not accent
t.color.border_focus = blue;
t.color.selection_bg = ui.Color.hex(0xC9D6F0);
t.color.list_selection_bg = ui.Color.hex(0xE6ECF7);
t.radius.lg = 10;
t.control.md.radius = 10;
t.control.md.padding_h = 14;
break :blk t;
};配一套暗色
暗色版本从 ui.theme.dark 派生,而不是在亮色品牌主题上改背景:暗色预设里的几十个背景、文字、边框值是成套调过的。品牌色在深色背景上通常要提亮,primary 按钮的文字改成深色。
pub const brand_dark: ui.ThemeTokens = blk: {
var t = ui.theme.dark; // start from the dark preset, not from brand
t.name = "brand_dark";
t.scheme = .dark; // Notification palettes and DevTools key off this
const blue = ui.Color.hex(0x7FA2F0); // lighter on dark backgrounds
t.color.accent = blue;
t.color.button_primary_bg = blue;
t.color.button_primary_fg = ui.Color.hex(0x0D1119);
t.color.border_focus = blue;
break :blk t;
};
brand(亮)
brand_dark
ui.theme.dark(默认)哪些 token 影响哪些组件
下表按组件实际读取的字段整理(v0.1.0-alpha 源码),改主题时先改这些:
fg_primary · fg_secondary · fg_tertiary · fg_disabled几乎所有文字与图标bg_primary · bg_secondary · bg_tertiary面板、卡片、Calendar、Chip、Progress / Slider 轨道、Skeletonbg_hover · bg_active可点击控件的悬停与按下accentCheckbox / Switch 选中、链接、Tabs、Breadcrumb、日期选中、Badgebutton_primary_bg · button_primary_fgprimary Button(hover / 按下从背景推导);fg 也用于 Badge、Chip、Tabs 上的反色文字border · border_strong · separator边框与分隔线border_focus · input_bg · input_border · selection_bgInput、Select、NumberStepper、DatePicker 等输入类控件success · warning · danger · info · status_fgAlert、Badge、Tag、Progress、danger Buttonlist_hover_bg · list_selection_bgMenu、DropdownMenu、ComboBox、Table、Tree、DataTablecheckbox_* · switch_thumb · switch_track_offCheckbox 与 Switch 的未选中、禁用态tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip、Modal / Sheet 遮罩、滚动条少数组件带有自己的配色,不从 ColorTokens 取色:Notification 按 scheme 在内置的亮 / 暗两套调色板之间选择;GlassBox 的玻璃着色、Progress 与 Select 的部分细节也是固定值。它们会随明暗切换,但不会换成你的品牌色。
圆角、间距与控件尺度
非颜色的 token 可以按字段覆盖。最有用的是 control:Button、Input、Select、ComboBox、DatePicker、DateRangePicker、Tabs、Chip 共享 xs / sm / md / lg 四档度量。控件的外框高度不是 token,而是算出来的:
xs2.5 × 2 + 12 × 1.25 = 20sm4.5 × 2 + 12 × 1.25 = 24md7.25 × 2 + 14 × 1.25 = 32lg10 × 2 + 16 × 1.25 = 40所以想让 md 控件更高,就改 t.control.md.padding_y 或字号,同档的所有控件会一起变;不要去给控件写死 height。图标尺寸 icon_size 要保持不大于行高。
运行时切换
cx.setTheme 可以随时调用:它替换 token 指针,在 cx.root 子树(包括浮层 portal)上重放 on_theme / before_render hook 并请求重绘。
const Prefs = struct {
cx: *ui.Cx,
dark: bool = false,
pub fn toggle(self: *Prefs) void {
self.dark = !self.dark;
self.cx.setTheme(if (self.dark) &themes.brand_dark else &themes.brand);
}
};
// In mountUI:
const prefs = try cx.bindState(Prefs, .{ .cx = cx });
cx.setTheme(&themes.brand);
// …
.on_click = cx.on(Prefs, prefs, Prefs.toggle),// A reactive read of the current theme (lives as long as `scope`).
const theme_sig = try cx.themeSignal(scope);
const is_dark = theme_sig.get().scheme == .dark;发布前检查
- ✓
scheme与背景明暗一致(暗底必须是.dark)。 - ✓
button_primary_fg与button_primary_bg、status_fg与danger的对比度至少 4.5:1——内置预设的单元测试就是这样检查的。 - ✓
border与bg_primary仍然分得清(预设测试要求对比度 ≥ 1.2)。 - ✓
在 Storybook 里切到你的主题,把常用组件都点一遍(可以参考 Component Wall story)。