Files
UI/packages/tools/cli/recipes.md
T

442 lines
24 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 组合配方(Recipes)
> 单个组件的参数表说明「怎么用」,这些配方说明「怎么拼」。所有配方都只消费组件对外 API,不依赖内部文件。
## 配方 1:表单校验 + 提交反馈
```ts
const name = signal('');
const email = signal('');
const formRef = form({ onSubmit: (ok) => (ok ? result({ status: 'success', title: '已保存' }) : toast.error('请检查表单')) }, [
formItem({ label: '名称', value: name, required: true, rules: [{ min: 2 }] }, textField({ value: name })),
formItem({ label: '邮箱', value: email, required: true, rules: [{ pattern: /^\S+@\S+$/ }] }, textField({ value: email })),
]);
```
要点:
- 校验规则挂在 `formItem`,值信号只有一个来源(`value` 同时给 `formItem` 与控件),避免两处各存一份。
- 提交结果用 `toast`(轻量、瞬时)或 `result`(整页终态)表达,不要两者同时弹。
## 配方 2:危险操作二次确认
```ts
const open = signal(false);
const dangerOpen = (key: string) => {
open(true);
return modal({
title: `确认删除 ${key}?`,
open,
size: 'sm',
onClose: () => open(false),
footer: h('div', { class: 'row' },
button({ label: '取消', variant: 'ghost', onClick: () => open(false) }),
button({ label: '删除', variant: 'danger', onClick: () => { open(false); toast.success('已删除'); } })),
}, h('p', null, '删除后不可恢复。'));
};
```
要点:
- `modal` 自带焦点陷阱与 Esc 逐层关闭,不要在业务层再挂 Esc 监听。
- 确认按钮用 `variant: 'danger'`,取消用 `ghost`,主次分明。
## 配方 3:表格筛选 + 分页 + 批量操作
```ts
const picked = signal<string[]>([]);
const page = signal(1);
dataTable({
data: rows,
columns: [{ key: 'name', header: '名称', sortable: true }, { key: 'city', header: '城市' }],
pageSize: 10,
page, // 同一 page 信号也给 pagination,二者自动联动
selectable: true,
selected: picked,
loading: saving,
});
pagination({ page, pageSize: 10, total: rows.length, jumpable: true, onChange: (p) => page(p) });
```
要点:
- `page` 信号是表格与分页器的唯一共享状态;外部改页码前自己钳制范围(core 的 `setPage` 不做钳制)。
- 选中一律存 `rowKey`,翻页/筛选后仍然稳定;全选只作用于当前可见行。
- 批量操作按钮在 `picked().length === 0` 时禁用,避免空提交。
## 配方 4:长列表数据加载与空态
```ts
const loading = signal(true);
const items = signal<ListItem[]>([]);
list({ items: items(), bordered: true, emptyText: loading() ? '加载中…' : '暂无数据' });
```
要点:
- 空态文案随加载态切换;列表本身会在 `items` 为空时渲染 `empty`,不需要业务层再判断一次。
- 需要骨架屏就换成 `skeleton`,加载完成再 `renderList` 成真实行。
## 配方 5:数据表 + 独立分页器 + 导出
```ts
const page = signal(1);
const table = dataTable({
data: rows,
columns: [
{ key: 'name', header: '姓名', sortable: true, width: 160 },
{ key: 'city', header: '城市', align: 'center', width: 'flex' },
],
page, // 受控页码:与外部 pagination 双向联动
selectable: true,
exportable: true, // 导出筛选 + 排序后的全量行(不是当前页)
exportName: '用户列表.csv',
});
const pager = pagination({ page, pageSize: 5, total: rows.length, jumpable: true, pageSizes: [5, 10, 20] });
```
要点:
- 页码只保留一个来源:`dataTable` 与 `pagination` 共传同一个 `page` 信号,翻页由内核写回,桥接层会钳制越界值。
- `exportable` 导出的是「筛选 + 排序后、未分页」的全量行,列序与表头一致、单元格走 `format`;想导出当前页请自行读 `getVisibleRows()`。
- 列宽拖拽只对 `width` 给数字的列生效(`'flex'` 列交给浏览器自适应);双击手柄回到声明宽度。
- 行选择一律按 rowKey 记账,筛选换掉可见行后已选仍在,批量操作前用已选计数确认。
## 配方 6:长表单编辑页(吸底操作条 + 提交锁定)
```ts
const saving = signal(false);
form(
{
onSubmit: (ok) => {
if (!ok) return; // 失败时汇总条已定位到第一处错误,无需自己数红框
saving(true);
save().then(() => toast.success('已保存')).finally(() => saving(false));
},
},
[
formItem({ label: '标题', value: title, required: true, rules: [{ max: 20 }] }, textField({ value: title, maxCount: 20 })),
formItem({ label: '权重', value: weight, hint: '0–100%,一位小数' }, inputNumber({ value: weight, min: 0, max: 100, step: 0.1, precision: 1 })),
formItem({ label: '上架', value: online }, switchButton({ label: '保存后自动上架', checked: online, loading: saving })),
],
);
affix({ position: 'bottom', offset: 0 }, h('div', { class: 'row' },
button({ label: '保存', type: 'submit', disabled: saving }),
));
```
要点:
- 长表单的操作条用 `affix({ position: 'bottom' })` 贴底固定,翻到表单底部按钮也不丢;提交按钮 `type: 'submit'` 走 `form` 聚合校验。
- 校验失败时 `form` 顶部的汇总条会播报「有 N 项需要修改」并聚焦第一处(`errorSummary: false` 可关);`hint` 写格式说明,报错时自动让位。
- 提交期间用同一个 `saving` 信号锁三处:按钮 `disabled`、开关 `loading`(转圈 + aria-busy)、业务请求出口,防重复提交与中途改状态。
- 长度约束一处写够:`textField` 的 `maxCount` 设原生上限并实时计数,`formItem` 的 `rules: [{ max }]` 只做提交兜底,两处数字保持一致。
## 配方 7:筛选栏(层级归属 + 日期区间)
```ts
const dept = signal('');
const from = signal('');
const to = signal('');
space({ size: 'sm', wrap: true },
treeSelect({ nodes: orgTree, value: dept, searchable: true, placeholder: '归属部门' }),
datePicker({ range: true, value: from, end: to, onRangeChange: (a, b) => reload(a, b) }),
button({ label: '重置', variant: 'ghost', onClick: () => { dept(''); from(''); to(''); } }),
);
```
要点:
- 层级用 `treeSelect`(允许选中间节点、可搜索),只有「必须逐级选到叶子」时才用 `cascader`。
- 日期区间传 `range: true`:组件内部保证先后顺序(用户点早了会自动交换),`onRangeChange` 拿到的就是排好序的两端。
- 重置把三个信号清空即可:组件都是受控的,值一变界面自己回到占位态。
## 配方 8:评论输入(@ 提及 + 参与人头像组)
```ts
const text = signal('');
const members = [{ name: 'zhangsan', label: '张三', description: '前端', keywords: ['zs'] }];
card({ title: '评审讨论', extra: avatarGroup({ items: members, max: 4 }) },
mentions({ items: members, value: text, placeholder: '输入 @ 叫人评审…', onSelect: (m) => toast.info(`已提及 ${m.label}`) }),
space({ size: 'sm', justify: 'end', block: true },
button({ label: '发送', onClick: () => { toast.success('已发送'); text(''); } })),
);
```
要点:
- 参与人头像用 `avatarGroup`:超出 `max` 自动折叠成 `+N`,整组 `title` 列出全部姓名,比并排 `avatar` 省一半横向空间。
- `mentions` 的 `value` 是受控信号:发送后 `text('')` 即清空,不要在组件外部再改 textarea。
- 候选人来源由调用方决定(`items` 可随 `onInput` 远程拉取后重建),组件不做远程搜索。
## 配方 9:详情页排版(栅格 + 排版文本 + 描述列表)
```ts
grid({ columns: 2, gutter: 'lg' },
card({ title: '基本信息' },
typography({ variant: 'title', level: 4, content: '订单 RB-2048' }),
descriptions({ column: 2, items: [
{ label: '金额', value: '¥1,280.00' },
{ label: '状态', value: tag({ label: '待发货', variant: 'warning' }) },
] }),
),
card({ title: '备注' },
typography({ variant: 'paragraph', ellipsis: 3, content: longNote }),
typography({ content: 'RB-2048-0917', code: true, copyable: true }),
),
);
```
要点:
- 页面骨架用 `grid`(`columns` 固定列 / `min` 走 auto-fit),窄屏自动单列,不要在调用方写媒体查询。
- 标题一律 `typography({ variant: 'title', level })` 出语义 `h1-h5`;正文长文本用 `ellipsis` 截断并自动挂 `title` 看全文。
- 只读字段用 `descriptions`(不要用 `form`):`form` 承载输入与校验,`descriptions` 只负责 label:value 对齐。
## 配方 10:暗色主题切换(只切 data-theme,不改令牌名)
```ts
const saved = localStorage.getItem('ua-theme');
const dark = signal(saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches);
effect(() => {
document.documentElement.dataset.theme = dark() ? 'dark' : 'light';
document.documentElement.style.colorScheme = dark() ? 'dark' : 'light'; // 原生控件(date/滚动条)跟着变
localStorage.setItem('ua-theme', dark() ? 'dark' : 'light');
});
floatButton({ label: '切换主题', onClick: () => dark(!dark()) });
```
要点:
- 只改 `[data-theme]` 的取值:暗色是令牌**值**的另一套,名字永远不变,组件样式一律不写主题判断。
- 初值顺序是「先 localStorage、再 prefers-color-scheme」;反过来会让用户的手动选择被系统偏好覆盖。
- 同步设置 `color-scheme`,否则原生控件(日期选择、滚动条)在暗色下仍是白底。
## 配方 11:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉)
```ts
const price = signal(200); // 价格下限
const priceTop = signal(800); // 价格上限
const from = signal(''); // 日期区间起点
const to = signal(''); // 日期区间终点
const stack = signal(''); // 技术栈(分组下拉)
grid({ columns: 3, gutter: 'md' },
slider({ value: price, end: priceTop, range: true, min: 0, max: 2000, step: 50 }), // 双滑块:两端都受控
datePicker({ range: true, value: from, end: to, presets: [{ label: '最近 7 天', days: 7 }, { label: '最近 30 天', days: 30 }] }),
select({ value: stack, options: [
{ value: 'vue', label: 'Vue 3', group: '前端框架' },
{ value: 'react', label: 'React 19', group: '前端框架' },
{ value: 'node', label: 'Node.js 22', group: '服务端运行时' },
{ value: 'deno', label: 'Deno 2', group: '服务端运行时' },
] }),
);
dataTable({ columns, data: rows, selectable: true }); // 表格只吃派生出来的查询条件
```
要点:
- 三个筛选控件全部受控于信号:查询参数从信号派生(`computed`),表格不另存一份筛选状态,否则「重置」要逐处清。
- 区间类控件都自带先后与越界修正:`slider` 的 `value`/`end` 越界会压平,`datePicker` 点早了会自动交换两端——调用方不要再写 clamp。
- 长候选集给 `option.group` 分块、常用时间段给 `presets`(`days` 相对今天,跨月跨年由组件算),比让用户逐行扫列表更快。
## 配方 12:命令面板(⌘K 全局操作入口)
```ts
const cmdkOpen = signal(false); // 面板显隐:快捷键与按钮共用同一份状态
const cmdk = commandPalette({
open: cmdkOpen,
items: [
{ id: 'new-order', label: '新建工单', group: '快捷操作', hint: '⌘N' },
{ id: 'export', label: '导出当前列表', group: '快捷操作' },
{ id: 'invite', label: '邀请成员', group: '协作', keywords: ['yaoqing', 'yao qing'] },
{ id: 'theme', label: '切换暗色主题', group: '偏好', keywords: ['dark'] },
],
onSelect: (item) => {
if (item.id === 'theme') return toggleTheme(); // 立即型命令:自己带副作用
if (item.id === 'invite') return inviteModal.open(); // 需要表单的交给已有弹窗,面板只做入口
toast.success(`已执行:${item.label}`); // 其余执行完给一次轻反馈
},
});
document.body.append(cmdk.el); // 面板是 fixed + 遮罩,挂 body 最省事
```
要点:
- 用 `id` 做命令分发键、`label` 只负责显示:以后改文案不会打断路由;同一 `id` 也是埋点的事件名。
- 过滤看三层 `label` / `hint` / `keywords`:拼音与别名塞 `keywords`(如 `yaoqing`),用户没切输入法也能命中。
- 同组必须**连续排列**才会合并成一段标题,`items` 顺序即展示顺序,组件不排序——按「快捷操作 / 跳转 / 偏好」排好再传。
- 需要二次确认的命令在 `onSelect` 里开 `modal` / `popconfirm`,别在面板内部再叠一层确认,否则 Esc 栈会有两层。
- 一页只留一个实例用缺省 `hotkey`(⌘K / Ctrl+K),其余传 `hotkey: false` 自己绑,否则一次按键会同时开两个面板。
- 命令集随权限变化时**重建实例**(`items` 是创建时快照),不要往原数组里 push。
## 配方 13:预约时点(日期 + 时间两段拼装)
```ts
import { computed, signal } from '@ui-arch/frame';
import { datePicker, form, formItem, timePicker, toast } from '@ui-arch/ui';
const day = signal(''); // 日期段:YYYY-MM-DD
const clock = signal('09:00'); // 时间段:HH:mm(给初值,用户只需要改自己想改的那半)
const at = computed(() => (day() ? `${day()} ${clock()}` : '')); // 拼装交给业务层:两个组件各管一段,互不知道对方
const dateField = formItem(
{ label: '预约日期', value: day, required: true },
datePicker({ value: day, min: new Date().toISOString().slice(0, 10) }), // 早于今天的日期直接置灰,比事后校验更省事
);
const timeField = formItem(
{ label: '预约时间', value: clock, required: true, hint: '每 15 分钟一档' },
timePicker({ value: clock, minuteStep: 15 }), // 步长只影响分列,时列始终 24 档
);
form({
layout: 'horizontal',
submitText: '确认预约',
items: [dateField, timeField],
onSubmit: () => toast.success(`已预约 ${at()}`), // 两段都非空才会走到这里:required 已经拦住半截状态
});
```
要点:
- **两段各管一段**:`datePicker` 只写 `YYYY-MM-DD`、`timePicker` 只写 `HH:mm`,拼成完整时点交给业务 `computed`——不要试图让某个组件同时产出日期与时间,否则一半值缺失时格式全靠猜。
- **时间给初值**:`clock` 预置 `09:00` 这类合理默认,用户多数只想改小时;日期保持空值,避免「默认今天」被误提交。
- **约束优先于校验**:日期上限下限用 `datePicker` 的 `min` / `max` 置灰,时间粒度用 `minuteStep` 收窄,`required` 只兜底空值——能点不出来的选项比报错文案更友好。
- **半截状态别提交**:`form` 的 `required` 依赖各自 `value` 信号,日期没选时提交会被拦下并定位到该字段;不要自己写 `if (!day()) return` 之类的旁路判断。
- 需要区间就用 `datePicker({ range: true })` + 两个 `timePicker`(起 / 止),拼装时各自独立,别指望一个信号同时表达两端。
## 配方 14:标签筛选条 → 表格联动
```ts
import { computed, signal } from '@ui-arch/frame';
import { button, dataTable, inputTag, select, space } from '@ui-arch/ui';
const tags = signal<string[]>(['本地推理']); // 条件条:可增删的多个标签(「且」关系)
const city = signal('all'); // 地区:单值下拉,all 表示不限
const rows = signal(listFromApi()); // 全量数据集(轮询 / 搜索时整体替换)
// 派生行集合:信号一变就重算,直接喂给表格的信号数据源
const matched = computed(() =>
rows().filter((row) =>
(city() === 'all' || row.city === city()) &&
tags().every((need) => row.tags.includes(need)), // 多标签是「且」:逐条都要命中
),
);
space({ gap: 'sm' }, [
inputTag({ value: tags, max: 5, placeholder: '按标签筛选,回车添加' }),
select({ value: city, options: [{ value: 'all', label: '全部地区' }, { value: 'sh', label: '上海' }] }),
button({ label: '重置', variant: 'ghost', onClick: () => { tags([]); city('all'); } }),
]);
const table = dataTable({ data: matched, columns, pageSize: 10 }); // 信号数据源:换源不重置排序与页码
```
要点:
- **筛选在信号层做,不在表格里做**:`dataTable` 的工具栏筛选是「关键字单列」的轻量搜索,多条件组合交给 `computed` 派生行集合,表格只负责渲染当前结果。
- **给表格传信号而不是数组**:`data: matched`(`Computed<T[]>` 同样是 `Signal<T[]>`)换源时内核保留排序、每页行数、当前页,并按 `rowKey` 剔除已消失行的选中态——手写 `replaceChildren(dataTable(...))` 会把这些状态全部丢掉。
- **条件条本身就是一个受控信号集合**:`inputTag` 的 `value` 就是唯一数据源,删除 chip 与增删条件走同一出口,别在业务层再存一份「当前条件」。
- **「且」还是「或」写在派生里**:`every` 是且(收窄)、`some` 是或(放大),两种语义只差一个方法调用,但决定了空条件时的行为——空数组 `every` 恒为真(等于不限),`some` 恒为假(等于无结果),重置逻辑据此写。
- 结果为空时不要另写空状态:`dataTable` 已内置空行提示,筛选无命中会自己显示「没有匹配的行」。
## 配方 15:表格分页与独立分页器联动(每页条数 + 受控页码)
表格自带的分页条(上一页 / 页码播报 / 下一页 / 每页条数)只够单表自用;需要把页码提到页面上方、或让多个表格共用一个分页器时,用 `page` 信号把两边的页码接起来,条数候选交给 `pageSizes`。
```ts
const page = signal(1); // 唯一页码来源:表格与独立分页器共读共写
const size = signal(10); // 每页条数由业务决定,表格只负责渲染
const table = dataTable({
data: rows, // 数组或信号均可;换源保留排序、页码与选中态
columns,
page, // 受控页码:内部翻页实时写回,外部置值即跳页
pageSize: size(),
pageSizes: [10, 25, 50], // 分页条候选档;当前值不在候选里会自动补进去
selectable: true,
});
pagination({ page, total: () => rows().length, pageSize: size() }); // 独立分页器与表格共用同一页码信号
```
要点:
- **一个页码只有一个来源**:`page` 信号既是表格的内部页码,也是独立分页器的输入;两边各自派发、等值写回短路,不会互相推着翻页。
- **改页大小要回到第 1 页**:`pageSize` 变化时表格内部按内核语义回第 1 页,外部只需把 `size` 改掉,别在业务层再手动 `page(1)`(顺序错了会出现「先跳到 25 条的第 3 页再被推回第 1 页」的闪动)。
- **越界页码由内核收敛**:数据缩水后 `state` 里的页码会被回写成实际生效页,数据恢复也不会突然跳回旧页码——所以外部依赖这个信号做联动时不需要自己钳制。
- **每页条数下拉要给名字**:无可见 label 的场合(分页条、工具栏)传 `ariaLabel`,否则读屏只念出一句「10 条/页」而没有主语。
## 配方 16:后台页骨架
```ts
layout(
{
header: h('div', { class: 'row' }, breadcrumb({ items: [{ label: '订单' }, { label: '列表' }] })),
sider: menu({ items: [{ key: 'overview', label: '概览' }, { key: 'orders', label: '订单' }] }),
footer: '© 2026 内部系统',
collapsible: true, // 页头左侧出现折叠开关
sticky: true, // 长内容页让页头吸顶
},
card({ title: '本月订单' }, dataTable({ data: rows, columns })),
)
```
要点:
- 页头给 `breadcrumb`、侧栏给 `menu`、主体给 `card`:`layout` 只负责四区位置与页头吸顶,导航与内容各归其组件,别在 `layout` 上塞交互。
- 折叠态想接到别处(例如只显示图标栏)就传 `collapsed` 信号读值;不给信号则内部自管,不必自己再 `signal(false)`。
- 内容区自带滚动与统一细条,不要再套一层 `overflow: auto`——两层滚动容器会让吸顶与滚动条重复出现。
## 配方 17:表格刷新遮罩
```ts
const busy = signal(false);
const table = dataTable({ data: rows, columns, page, selectable: true });
const refresh = async () => {
busy(true);
try { await reload(); } finally { busy(false); } // 遮罩只跟 busy 走,异常也要收
};
loading({ active: busy, text: '正在刷新订单', minHeight: 240 }, table);
```
要点:
- 遮罩包住表格而不是整页:用户看得见「哪一块在等」,其余区域仍可操作。
- `loading` 默认延迟 120ms 出场——接口快于此不闪;别为了「一定看见」传 `delay: 0`,那会让每次快请求都闪一下。
- 刷新期间不要把 `data` 换成空数组占位:`loading` 已负责等待感,清空数据会让页码与选中态无谓地收敛一次。
## 配方 18:变更审阅面板
```ts
const panel = h('div', { class: 'review' },
tag({ label: '待审阅', variant: 'warning' }),
codeBlock({ code: before, title: '旧版本', lang: 'ts', maxHeight: 220 }),
diffView({ before, after }),
copyButton({ text: after, label: '复制新版本' }),
);
```
要点:
- 三段各司其职:`codeBlock` 看全文(带行号与复制)、`diffView` 只看变化、`copyButton` 把新版本带走——不要用 `diffView` 代替全文展示。
- `codeBlock` 的行号不参与复制,`copyButton` 直接读原文,所以「复制出来的代码」永远干净。
- 差异行多了给 `diffView` 外层限定高度(或放进抽屉),否则长差异会把审阅按钮挤出视口。
## 配方 19:可排序的检查单(拖拽 + 键盘搬位 + 顺序回写)
```ts
const steps = ['需求评审', '接口联调', '灰度发布', '线上回归'];
const order = signal(steps); // 顺序唯一真相:组件与下方摘要读同一个信号
const list = sortableList({
items: steps, // 文案即排序键,要求唯一
value: order,
onChange: (next) => toast.success(`已调整:${next[0]} 置顶`),
});
card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' },
list.el, // 返回的是句柄不是节点:忘了 append(el) 页面上一片空白
h('p', { class: 'desc' }, () => order().map((t, i) => `${i + 1}. ${t}`).join(' ')),
button({ label: '把「灰度发布」置顶', variant: 'ghost', onClick: () => list.move(2, 0) }),
);
```
要点:
- `sortableList` 返回 `{ el, move, order }` 句柄(与 `form` / `virtualList` 同类):节点在 `el` 上,业务侧要改顺序用 `move(from, to)`,读当前顺序用 `order()`。
- 顺序只存一份:`value` 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。
- `move()` 与拖拽走同一条提交路径(都会触发 `onChange`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。
- 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。
- 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。
## 通用约定
- 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。
- 反馈分层:`toast` 右上角瞬时、`notification` 右下角可长读、`result` 整页终态、`alert` 页内常驻。
- 样式不要写颜色与间距字面值,先跑 `ua tokens` 查 `--ua-*`(verify 会拦截未定义令牌与 `#hex`)。
- 动效与弹层方向不要各写一份:入场用 `ua-pop-drop`、翻到触发器上方用 `ua-pop-rise`(`.is-flip`),时长缓动走 `--ua-duration-*` / `--ua-ease-*`;verify 会拦「帧体完全相同但名字不同」的复制粘贴。
- 新增可滚动容器(自绘列表 / 代码块 / 面板)时,把类名加进 `ui.css` 顶部那条 `scrollbar-width` 统一规则里,否则暗色下会露出系统原生亮条;verify 会逐个核对 `overflow: auto|scroll` 的容器是否都在名单内。
- 新增颜色令牌要同时改 `tokens.css`(`:root` + `[data-theme='dark']`)与 `tokens.json`(`color.*.light/dark`):只改一处时文档色板会凭空多/少一个色块,verify 的双源一致性校验会直接报错。