# @ui-arch 组件库 Agent 文档（无图形版）

> 自动生成于 2026-10-26T16:34:08.281Z，数据源：各包 ua.manifest.json，勿手改。人类版文档站：apps/docs/index.html。
> 组件 = 普通函数，传 props 返回 DOM；状态用 signal 双向绑定：读 signal() 写 signal(v)。

## 快速上手

### @ui-arch/frame

1. 引入：import { signal, computed, effect, h, mount } from '@ui-arch/frame'
2. 声明状态：const count = signal(0)；无参调用读值 count()，传参写值 count(1)
3. 渲染视图：mount('#app', () => h('div', null, () => `count = ${count()}`))——函数型子节点即响应式文本
4. 绑定事件：props 里 on 开头且值为函数的键（onClick 等）自动 addEventListener
5. 派生数据：const double = computed(() => count() * 2)，视图里直接调用 double()，依赖自动追踪
6. 列表渲染：renderList(tbody, () => rows.map(r => h('tr', null, r.name)))，信号变化整体替换子节点

### @ui-arch/table-core

1. 创建实例：const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()] })
2. 取派生数据：table.getVisibleRows() 返回 { rows, total }；total 语义固定 = 筛选后、分页前的行数
3. 变更状态：只能 dispatch action，如 table.dispatch({ type: 'toggleSort', key: 'name' })；状态不可变
4. 订阅刷新：table.subscribe(fn)，fn 里重新 getVisibleRows() 并交给渲染层
5. 分页信息：api.getPageInfo() 返回 { page, pageSize, totalPages }；越界页码自动钳制
6. 扩展能力：实现 TablePlugin（derive / patchState / settle / api 四钩子）传入 plugins 即插入管线

### @ui-arch/tokens

1. 引入：在应用入口 import '@ui-arch/tokens/tokens.css'
2. 消费：组件样式一律 var(--ua-color-accent) 这类变量，不写死色值
3. 暗色：根元素设 data-theme=dark，变量值自动切换
4. 扩展：新令牌先同时加进 tokens.css 与 tokens.json，再重新生成文档站

### @ui-arch/ui

1. 安装依赖：pnpm add @ui-arch/ui @ui-arch/frame（workspace 内自动链接）
2. 引入样式：在应用入口 import '@ui-arch/ui/ui.css'，暗色主题在根元素设 data-theme='dark'
3. 创建状态：import { signal } from '@ui-arch/frame'，如 const name = signal('')
4. 调用组件：const el = textField({ value: name })，把 el 挂到你的容器或配合 h() 组合
5. 读写状态：name() 读、name('张三') 写，界面自动同步，无需手动操作 DOM

### @ui-arch/cli

1. 接手先跑 ua doctor（11 项：node / 依赖 / git 钩子与身份 / 元数据 / 命令面三方一致 / 门禁链完整，秒级），再跑 npm run check 确认基线全绿（build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion）
2. 查组件：ua list 看全量，ua search <关键词> 按 id/标题/参数名/参数说明/步骤五层 + 配方三层（标题/配方组件/要点）模糊找（命中非 id 标题会标出命中字段，配方命中行直接给 ua recipes <编号> 指路；拼错会回相近 id），ua info <组件id> 看 props/步骤/示例（ua info <包名> 看单包 api 与约定，加 --json 出原始清单）；要给文档 / issue / 回复贴组件说明时加 --md，直接拿到标题 + 参数表 + 用法 + 示例的 Markdown 片段
3. 查配方：ua recipes 列全量（编号 / 标题 / 用到的组件），ua recipes <编号> 看骨架代码与要点（--json 给脚本用；MCP 里对应 list_recipes / get_recipe）
4. 查令牌：ua tokens 打印全部 --ua-* 变量（颜色带暗色值），--json 输出结构化数组；写样式前先查，禁止硬编码。令牌分十三组：颜色 / 混色浓度 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 位移 / 焦点环 / 控件高度 / 层级，color-mix 混入浓度用 --ua-alpha-soft/tint/line/mid/strong/heavy，弹层阴影用 --ua-shadow-1..4，过渡用 --ua-duration-* + --ua-ease，transform 位移用 --ua-translate-*（负号 calc(-1 * var(...))），焦点环用 --ua-focus-ring(_inset)
5. 收口令牌面用 ua tokens --unused：列出皮肤样式表与令牌自身都没引用的令牌（当前 3 个：surface-3 / text-secondary / font-size-xl），删之前先确认没有外部消费方在吃它们
6. 改完清单跑 npm run docs：同时刷新人类文档站与 llms-full.txt（Agent 首选喂料）
7. 改完样式跑 npm run audit:visual：各类硬编码计数不得高于 .ua/visual-baseline.json（棘轮只减不增）；字面量可用 npm run visual:migrate 幂等改写成令牌
8. 改完颜色令牌跑 npm run audit:contrast：18 组前景/底色配对在亮/暗 × 常规/高对比四套主题下都必须达标（正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3），跌破即退出码 1——调一个色值就能让全站辅助文字掉到 4.4 或让焦点环糊进背景，人眼看不出但门禁看得出
9. 改完动效跑 npm run audit:motion：带 infinite 的动画必须在 prefers-reduced-motion 块里被关掉，悬停 / 按压的位移（translate）必须归零，缺一条即退出码 1——循环动画漏兜底会把「减少动效」用户整页交给抖动，位移漏兜底则每次悬停都在推搡前庭
10. 在 ~/.workbuddy/mcp.json 注册 ua mcp 后，Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe；get_component 传 format: md 拿到与 ua info --md 同款的 Markdown 片段

## @ui-arch/ui（skin-dom）v0.1.0

70 个 DOM 组件，统一用法：组件是普通函数，传入 props 返回 DOM 节点；需要状态时先创建 signal 再双向绑定。引入 CSS：import '@ui-arch/ui/ui.css'（内部已消费 tokens）。

阴影对照表：下拉弹层=shadow-2 / 浮层与悬浮按钮=shadow-3 / 模态弹窗与抽屉及通知=shadow-4 / 卡片与输入组轻浮起=shadow-1；主按钮与强调件的主题色投影=shadow-accent / shadow-accent-strong，组件内不得再手写 box-shadow 字面值。

浮层入场统一：新弹层用 animation: ua-pop-drop var(--ua-duration-fast) var(--ua-ease-out) both（自上而下 4px 淡入），弹层翻到触发器上方时叠 .is-flip 覆盖成 ua-pop-rise（自下而上），不要再为单个组件自造 keyframes——方向与时长各写一份就会出现「翻转后还朝下入场」的错位。

### image — 图片 Image

参数：

- src（必填）：string —— 图片地址（缩略图与大图共用）
- alt（默认 ''）：string —— 替代文本；缺省空串
- width：number —— 缩略图宽度像素，高度自适应；缺省走样式默认
- fallback（默认 '图片加载失败'）：string —— 加载失败时缩略图内显示的占位文案；缺省「图片加载失败」

示例：

```
image({ src: '/a.png', alt: '架构图', width: 160 })
```

手把手：

1. 基础用法只传 src：渲染缩略图，hover 上浮提亮，光标变放大镜
2. 点击缩略图（或聚焦后回车）打开全屏遮罩大图，Esc 或点击遮罩任意处关闭，焦点归还缩略图
3. 宽度用 width（像素）控制，高度按比例自适应，容器内不撑破 max-width
4. alt 尽量传有意义的描述，读屏与图片加载失败时都依赖它

现场演示：apps/docs/demo.html#sec-image

### button — 按钮 Button

参数：

- label（必填）：string —— 按钮文案
- variant（默认 primary）：'primary' | 'ghost' | 'danger' —— 视觉强度；缺省 primary
- type：'button' | 'submit' | 'reset' —— 原生类型；缺省 button，表单内不会意外提交
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onClick（回调）：() => void —— 点击回调
- size：'sm' | 'md' | 'lg' —— 高度档位：sm 紧凑 / md 默认 / lg 大触控目标，走 --ua-control-h-* 三档；缺省 md
- loading（默认 false）：boolean | Signal<boolean> —— 加载态：转圈并禁点（aria-busy 播报忙碌）；布尔直接置位，传信号则动态同步

示例：

```
button({ label: '提交', variant: 'primary', onClick: () => console.log('clicked') })
```

手把手：

1. 确定语义强度：主操作用 primary（默认），次要操作用 ghost，危险操作用 danger
2. 绑定 onClick 回调处理点击逻辑
3. 需要禁用态时传 disabled（布尔或信号）；异步提交用 loading：开始时置 true，完成后置 false，按钮转圈并自动禁点
4. 放进 <form> 里需要提交时显式传 type: "submit"；缺省 button 不会触发原生提交

现场演示：apps/docs/demo.html#sec-counter

### textField — 文本输入 TextField

参数：

- value：Signal<string> —— 双向绑定信号：输入写回信号，信号变化写回输入框
- placeholder：string —— 占位文案
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onInput（回调）：(value: string) => void —— 输入回调，每次按键触发
- prefix：string | HTMLElement —— 前置附加块（textField/inputNumber 在输入框内侧前、textarea 在上方）；缺省不渲染
- suffix：string | HTMLElement —— 后置附加块（textField/inputNumber 在输入框内侧后、textarea 在下方）；缺省不渲染
- clearable（默认 false）：boolean —— 开启后有值即显示一键清空按钮；缺省 false
- maxCount：number —— 字数上限：同时设原生 maxlength，并在输入框内侧显示实时计数；缺省不限
- size：'sm' | 'md' | 'lg' —— 高度档位：sm 紧凑 / md 默认 / lg 大触控目标，走 --ua-control-h-* 三档；缺省 md
- password：boolean —— 密码框：原生打点渲染，内置「显示/隐藏」切换按钮（aria-label 同步）；缺省普通文本

示例：

```
const name = signal(''); const el = textField({ value: name, placeholder: '输入名字…', onInput: (v) => console.log(v) })
```

手把手：

1. 先用 signal('') 创建字符串信号
2. 传入 value 完成双向绑定：用户输入自动写回，外部赋值自动回显
3. 在任意响应式位置用 name() 读取最新值，如 () => `你好，${name()}`
4. 需要图标、货币符、单位等前后缀时传 prefix/suffix（字符串或 DOM），控件会包进带边框的附加组，聚焦光环由组统一承载
5. 需要限制长度就给 maxCount：超限输入被原生拦截，输入框内侧实时显示 n/上限

现场演示：apps/docs/demo.html#sec-bind

### inputNumber — 数字输入 InputNumber

参数：

- value（必填）：Signal<number> —— 当前值信号，双向绑定（按钮/键盘写回、外部赋值同步）
- min：number —— 最小值；缺省无下限
- max：number —— 最大值；缺省无上限
- step（默认 1）：number —— 步长；缺省 1
- precision：number —— 小数位数：写回与显示都按 toFixed(p) 归一（消除步进浮点尾差，step 0.1 也干净）；缺省不处理
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onChange（回调）：(value: number) => void —— 值变化回调（已夹取到合法区间）
- prefix：string | HTMLElement —— 前置附加块（textField/inputNumber 在输入框内侧前、textarea 在上方）；缺省不渲染
- suffix：string | HTMLElement —— 后置附加块（textField/inputNumber 在输入框内侧后、textarea 在下方）；缺省不渲染
- size：'sm' | 'md' | 'lg' —— 高度档位：sm 紧凑 / md 默认 / lg 大触控目标，走 --ua-control-h-* 三档；缺省 md

示例：

```
const qty = signal(1); const el = inputNumber({ value: qty, min: 1, max: 99, onChange: (v) => console.log(v) })
```

手把手：

1. 用 signal(1) 创建数字信号并传入 value 完成双向绑定
2. 点击 −/+ 按钮或在输入框聚焦时按 ↑↓ 以 step 调节；直接输入在失焦或回车时解析
3. 非法输入自动回滚为当前值；min/max 超界自动夹取，disabled 支持布尔或信号禁用
4. 金额、比例这类小数场景传 precision：值与显示都固定小数位，步进不再出现 0.30000000000000004
5. 需要图标、货币符、单位等前后缀时传 prefix/suffix（字符串或 DOM），控件会包进带边框的附加组，聚焦光环由组统一承载

现场演示：apps/docs/demo.html#sec-inputnumber

### textarea — 多行输入 Textarea

参数：

- value：Signal<string> —— 双向绑定信号
- rows（默认 3）：number —— 初始行数；缺省 3
- placeholder：string —— 占位文案
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onInput（回调）：(value: string) => void —— 输入回调
- prefix：string | HTMLElement —— 前置附加块（textField/inputNumber 在输入框内侧前、textarea 在上方）；缺省不渲染
- suffix：string | HTMLElement —— 后置附加块（textField/inputNumber 在输入框内侧后、textarea 在下方）；缺省不渲染
- maxCount：number —— 字数上限：同时设原生 maxlength，并在下方附加块显示实时计数；缺省不限
- autosize：boolean | { minRows?, maxRows? } —— 自适应内容高度：true 跟随内容涨落，对象态钳最小/最大行数，超出 maxRows 内部滚动；缺省固定行数

示例：

```
const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话介绍…' })
```

手把手：

1. 创建信号并传入 value
2. 用 rows 控制初始高度，用户可纵向拉伸
3. 字数统计等派生逻辑直接在响应式文本里读 bio().length
4. 需要图标、货币符、单位等前后缀时传 prefix/suffix（字符串或 DOM），控件会包进带边框的附加组，聚焦光环由组统一承载

现场演示：apps/docs/demo.html#sec-form

### slider — 滑块 Slider

参数：

- value（必填）：Signal<number> —— 当前值信号，双向绑定：拖动写回，外部赋值同步位置
- min（默认 0）：number —— 最小值；缺省 0
- max（默认 100）：number —— 最大值；缺省 100
- step（默认 1）：number —— 步长；缺省 1
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- showValue（默认 true）：boolean —— 是否右侧显示当前值；缺省 true
- marks：{ value: number; label?: string }[] —— 刻度标注：按值定位在轨道下方，label 缺省显示数值；纯装饰不进读屏（数值由原生 input 播报）
- range（默认 false）：boolean —— 双滑块区间模式：value 当下限、end 当上限，两拇指之间用强调色连成已选段
- end：Signal<number> —— 区间上限信号；仅 range 模式使用，不传则组件内部维护
- onRangeChange（回调）：(low: number, high: number) => void —— 区间两端变更回调，参数已保证 low ≤ high（越界那一端会被压平）
- onChange（回调）：(value: number) => void —— 拖动回调

示例：

```
const vol = signal(40); slider({ value: vol, min: 0, max: 100, step: 5, onChange: (v) => console.log(v) })
```

手把手：

1. 创建数值信号（初值在 min/max 区间内），传入 value 完成双向绑定
2. 用 min / max / step 限定范围与粒度，如 step: 5 只取 5 的倍数
3. 拖动或按 ←→ 键改变值：原生 range 自带键盘与触屏交互，无需额外处理
4. 只想看拖动条不显示数字时传 showValue: false；需要自定义展示可自行读 signal
5. 禁用场景传 disabled（布尔或信号），置灰且不可拖
6. 需要在轨道下方标刻度（如 0 / 50 / 100 的档位含义）传 marks：[{ value, label }]，按 min-max 换算百分比定位，容器宽度变化时自动跟随
7. 要选数值区间就传 range: true 并给 end 信号：两条原生 range 叠在同一轨道，只有拇指可拖；拖下限越过上限会自动压平，回调走 onRangeChange(low, high)

现场演示：apps/docs/demo.html#sec-slider

### checkbox — 复选框 Checkbox

参数：

- checked：Signal<boolean> —— 勾选状态信号，双向绑定
- label：string —— 右侧文案；缺省只渲染勾选框
- indeterminate：boolean | Signal<boolean> —— 半选态（批量选择时部分子项选中）：仅影响视觉与读屏 mixed 播报，不改变 checked 值；点一下即回到全选
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onChange（回调）：(checked: boolean) => void —— 变更回调

示例：

```
const agreed = signal(false); checkbox({ label: '同意条款', checked: agreed, onChange: (v) => console.log(v) })
```

手把手：

1. 创建布尔信号 agreed = signal(false)
2. 传入 checked 与 label，勾选即写回信号
3. 表单提交前用 agreed() 判断是否勾选
4. 做全选/子项联动时把「部分选中」折算成 indeterminate（布尔或信号）：视觉显示横杠，读屏播报 mixed，点一下即全选

现场演示：apps/docs/demo.html#sec-form

### switchButton — 开关 Switch

参数：

- checked：Signal<boolean> —— 开关状态信号，双向绑定
- ariaLabel：string —— 无可见文案时的可访问名（如纯图标开关）；缺省取 label
- label：string —— 左侧文案
- loading：boolean | Signal<boolean> —— 加载中：锁定交互、滑块换成转圈并挂 aria-busy（异步提交防误点）；与 disabled 同时生效
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onChange（回调）：(checked: boolean) => void —— 变更回调

示例：

```
const night = signal(true); switchButton({ label: '夜间模式', checked: night })
```

手把手：

1. 创建布尔信号并传入 checked
2. 轨道与滑块的动画由 CSS 完成，无需干预
3. 状态变化在 onChange 里做副作用（如切换主题）
4. 切换要等异步请求就传 loading（布尔或信号）：请求期间滑块转圈、不可点，完成后自动恢复

现场演示：apps/docs/demo.html#sec-form

### select — 下拉选择 Select（自绘弹层）

参数：

- options（必填）：OptionItem[] —— 选项列表，每项 { value, label }；每项可加 group 指定分组小标题
- value：Signal<string> —— 选中值信号，双向绑定
- placeholder：string —— 未选中时触发器显示的占位文案
- searchable：boolean —— 弹层顶部带搜索框，按标签包含匹配过滤；缺省 false
- clearable：boolean —— 有值时触发器右缘出现清空按钮（收起态按 Delete / Backspace 也能清）；缺省 false，用于「可选筛选项」
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步；禁用时触发器置灰且不展开弹层
- ariaLabel：string —— 触发器的可访问名：没有可见 label 关联时（如表格分页条的「每页条数」）给读屏一个名字；缺省不落 aria-label
- onChange（回调）：(value: string) => void —— 选中回调
- size：'sm' | 'md' | 'lg' —— 高度档位：sm 紧凑 / md 默认 / lg 大触控目标，走 --ua-control-h-* 三档；缺省 md

示例：

```
const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], value: city, placeholder: '选择城市…' })
```

手把手：

1. 准备 options 数组，value 是提交用值、label 是展示文案
2. 创建信号并传入 value；未传时组件内部自持状态
3. 点击触发器展开弹层，点选项写回信号并收起；点外部或按 Esc 也会收起
4. 选中项在弹层中用淡色弱化标记，不抢视觉焦点
5. 弹层展开时按 Esc 只收起下拉并回焦触发器；若下拉在弹窗/抽屉内，再按一次 Esc 才关外层（逐层关闭）
6. searchable: true 时打开即聚焦搜索框，输入实时过滤；↑↓/Home/End 只在可见项间移动，无命中显示「无匹配选项」
7. 需要允许「不选」的筛选项就开 clearable：有值时右缘出现 ×，点它把值写成空串并回调 onChange('')，键盘在收起态按 Delete / Backspace 等价
8. 候选多时给选项加 group：相邻同组合成一段、组首插灰色小标题（段间有分隔线），过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项
9. 没有可见 label 的场合（工具栏、分页条）传 ariaLabel：触发器只显示当前值文案，读屏需要「这个下拉在选什么
10. 弹层展开期间滚动页面或任意滚动容器，会按视口重算上下翻转（与 tooltip 共用 position.ts 的 trackShift）；收起即摘监听

现场演示：apps/docs/demo.html#sec-form

### menu — 下拉菜单 Menu

参数：

- label（必填）：string —— 触发器文案
- items（必填）：MenuItem[] —— 菜单条目：{ value, label, disabled?, danger?, divider?, shortcut? }
- variant（默认 ghost）：'primary' | 'ghost' —— 触发器视觉强度；缺省 ghost
- align（默认 start）：'start' | 'end' —— 弹层对齐方向；缺省 start（与触发器左对齐）
- onSelect（回调）：(value: string) => void —— 选中回调，禁用项不触发

示例：

```
menu({ label: '操作', items: [{ value: 'edit', label: '编辑' }, { value: 'del', label: '删除', danger: true }], onSelect: (v) => console.log(v) })
```

手把手：

1. 准备 items 数组：value 是回调标识、label 是展示文案，插一条 { divider: true } 即可分出条目组
2. 传 label 决定触发器文案；默认 ghost 轻量样式，主操作用 variant: 'primary'
3. 点触发器展开弹层，点条目收起并触发 onSelect；点外部或按 Esc 也会收起（Esc 后焦点回到触发器）
4. 条目要给快捷键提示时传 shortcut（如 '⌘K'）：右侧灰字展示，组件不绑定按键，实际快捷键由调用方在全局监听
5. 键盘：Tab 聚焦触发器，↑↓ 展开并在可用条目间环形移动，Enter / Space 选中；disabled 项自动跳过
6. 靠右对齐避免溢出时传 align: 'end'；危险操作给条目加 danger: true
7. 展开时按 Esc 只收起菜单并回焦触发器，不会连带关闭所在弹窗（逐层关闭）

现场演示：apps/docs/demo.html#sec-menu

### radioGroup — 单选组 RadioGroup

参数：

- options（必填）：OptionItem[] —— 选项列表
- value：Signal<string> —— 选中值信号，双向绑定
- disabled：boolean | Signal<boolean> —— 整组禁用：全部选项与文案置灰且不可选
- onChange（回调）：(value: string) => void —— 变更回调

示例：

```
const size = signal('m'); radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size })
```

手把手：

1. 创建字符串信号，初值设为默认选项的 value
2. 传入 options 与 value，组内自动互斥
3. 读取 size() 获取当前选中值
4. 需要整组禁用时传 disabled：布尔值静态禁用，也可传信号（如 locked）实现动态锁定

现场演示：apps/docs/demo.html#sec-form

### accordion — 折叠面板 Accordion

参数：

- items（必填）：AccordionItem[] —— 面板列表：{ value, title, content, disabled? }
- active：Signal<string> —— 受控展开面板信号（单开模式）；缺省由组件内部持有状态
- multiple（默认 false）：boolean —— 允许多个面板同时展开；缺省 false（手风琴，单开）
- onChange（回调）：(active: string) => void —— 展开变化回调：收起时传空串

示例：

```
accordion({ items: [{ value: 'a', title: '账户设置', content: '内容…' }, { value: 'b', title: '通知', content: h('p', null, '…') }] })
```

手把手：

1. 准备 items：value 作标识、title 作标题、content 可为字符串 / DOM 节点 / 响应式函数
2. 默认手风琴模式：展开一个自动收起其他；需要同时展开多个时传 multiple: true
3. 需要外部控制或读取展开项时传 active 信号（受控单开），否则组件内部自持状态
4. 标题是原生 button：Tab 聚焦、Enter / Space 切换，展开态带 aria-expanded + region 关联
5. 内容区用 grid 行高 0fr → 1fr 过渡，无需测量高度；reduced-motion 用户自动静态切换

现场演示：apps/docs/demo.html#sec-accordion

### breadcrumb — 面包屑 Breadcrumb

参数：

- items（必填）：BreadcrumbItem[] —— 路径条目：{ label, href? }，末项自动视为当前页
- separator（默认 /）：string —— 分隔符；缺省 '/'
- onNavigate（回调）：(item, index) => void —— 无 href 条目的点击回调
- maxItems：number —— 最多展示几项：超出时首项之后折叠成「…」按钮（aria-label 报出折叠条数），点开即完整展开；缺省不折叠

示例：

```
breadcrumb({ items: [{ label: '首页', href: '/' }, { label: '设置' }, { label: '账户' }], onNavigate: (it) => console.log(it.label) })
```

手把手：

1. 按层级顺序准备 items，最后一项即当前页，自动渲染为不可点的灰色文字并带 aria-current='page'
2. 需要真实跳转就给条目加 href（渲染 <a>）；单页应用想自行接管时不给 href，改用 onNavigate 回调
3. 改分隔符传 separator，如 '›'；分隔符对读屏隐藏
4. 语义结构为 nav + ol + li，可直接放进页头或卡片标题栏
5. 路径过深时传 maxItems（如 4）：首项 + 「…」+ 末几项，点「…」原地展开全部，末项始终是当前页

现场演示：apps/docs/demo.html#sec-breadcrumb

### card — 卡片 Card

参数：

- title：string —— 标题；缺省不渲染标题行
- ...children：unknown[] —— 任意子节点：字符串 / DOM / 响应式函数
- extra：unknown —— 标题行右侧操作区（按钮 / 统计文案）；缺省不渲染
- footer：unknown —— 底部操作区；缺省不渲染
- bordered：boolean —— 是否显示边框；缺省 true
- hoverable：boolean —— 悬停浮起（可点击卡片）；缺省 false

示例：

```
card({ title: '用户表' }, dataTable({ data, columns }))
```

手把手：

1. card 是容器组件：第一个参数是 props，其余参数是任意子节点
2. 传 title 显示标题行，否则只有内容区
3. 子节点可以混合字符串、DOM 节点与响应式函数
4. title 与 extra 同处标题行：有 extra 时右侧对齐，没标题也能只放操作区
5. footer 渲染在内容之后的底部区，右对齐并带上分隔线，适合放主次按钮
6. bordered:false 去边框（嵌在已有边界的容器里），hoverable 让整卡悬停浮起（自带 reduced-motion 兜底）

现场演示：apps/docs/demo.html#sec-card

### badge — 徽标 Badge

参数：

- label：string —— 徽标文案；与 count / dot 三选一，dot 时作可访问名
- variant：'default' | 'success' | 'warning' | 'danger' —— 语义色；缺省 default
- count：number —— 数字角标：超过 max 显示 N+，负数归零
- max：number —— 数字上限；缺省 99
- dot：boolean —— 只显示小红点（无文字），role=img + label 作名称

示例：

```
badge({ label: '成功', variant: 'success' })
```

手把手：

1. 纯状态文案直接传 label，配 variant 定语义色
2. 要显示数量传 count（如未读数），超过 max（缺省 99）自动渲染成 99+
3. 只提示「有更新」用 dot: true，配合 label 给出读屏名（如「有未读消息」）
4. 不要给 badge 挂点击：它不是按钮（无 tabindex 也无键盘响应）；「点角标进消息中心」应当把 badge 塞进 button 里，让按钮承担语义

现场演示：apps/docs/demo.html#sec-feedback

### tag — 标签 Tag

参数：

- label（必填）：string —— 标签文案
- variant（默认 default）：'default' | 'success' | 'warning' | 'danger' | 'info' —— 语义色；缺省 default
- closable（默认 false）：boolean —— 显示 × 关闭按钮；缺省 false（纯展示）
- onClose（回调）：() => void —— 关闭回调：调用方从数据源移除该项后重新渲染
- checkable（默认 false）：boolean —— 可选中：渲染为筛选片（button + aria-pressed）；与 closable 互斥，同时传以 checkable 为准
- checked：Signal<boolean> —— 选中态信号，双向绑定（点击写回、外部赋值同步）；不传则组件内部自持
- onChange：(checked: boolean) => void —— 选中态变化回调，参数是点击后的新值

示例：

```
tag({ label: '前端', variant: 'info', closable: true, onClose: () => remove('前端') })
```

手把手：

1. 纯展示用 badge，需要可移除的标签（筛选项 / 已选条件）用 tag
2. 按语义选 variant：info 中性标记、success 通过、warning 待处理、danger 已失效
3. closable: true 时必须配 onClose：把数据源里的该项删掉再重渲染（组件无状态，不自行摘除节点）
4. 组合用法：容器里用 renderList 按信号数组渲染 tag 列表，删除即写回信号
5. 标签是内联元素，可直接排进 .row 等容器，自动换行由容器决定
6. 需要可点的筛选片时传 checkable + checked：点击写回信号，视觉与 aria-pressed 同步，不做整块重渲染（焦点不丢）

现场演示：apps/docs/demo.html#sec-tag

### avatar — 头像 Avatar

参数：

- name（必填）：string —— 名称：生成占位首字，并作为图片 alt 与悬停标题
- src：string —— 图片地址；缺省用首字占位
- size（默认 32）：number —— 直径像素；缺省 32
- variant（默认 default）：'default' | 'success' | 'warning' | 'danger' —— 占位底色语义档；缺省 default

示例：

```
avatar({ name: '张三' }); avatar({ name: 'Alice', size: 40, variant: 'success' })
```

手把手：

1. 只传 name：用名称首字做占位（中文取首字、英文大写），底色走 accent 淡化，暗色自动适配
2. 有图片就传 src：渲染 img 并用 name 作 alt，读屏语义完整；图片缺失时由浏览器 alt 兜底
3. 尺寸用 size（像素）控制，头像不参与布局拉伸（flex:none），可安全排进 .row 与表格单元格
4. 需要区分类别（在线/告警）时用 variant 换底色档，颜色一律来自令牌

现场演示：apps/docs/demo.html#sec-avatar

### alert — 提示条 Alert

参数：

- message（必填）：string —— 提示内容
- title：string —— 加粗标题行；缺省只渲染正文
- variant（默认 info）：'info' | 'success' | 'warning' | 'danger' —— 语义色；缺省 info
- closable（默认 false）：boolean —— 显示 × 关闭按钮，退场后从 DOM 移除；缺省 false
- onClose（回调）：() => void —— 关闭回调，供调用方同步业务状态
- banner（默认 false）：boolean —— 通栏形态：去圆角、只保留下边框、左右撑满，适合页面顶部通知
- showIcon（默认 false）：boolean —— 显示语义图标（info ℹ / success ✓ / warning ! / danger ✕），纯装饰不进读屏
- icon：string | HTMLElement —— 自定义图标节点，传了即覆盖语义图标

示例：

```
alert({ message: '操作可能存在风险', variant: 'warning' })
```

手把手：

1. 按信息级别选 variant：info 提示、success 成功、warning 警告、danger 错误
2. 传入 message 即可，入场自带下滑动画
3. 需要可关闭或带操作按钮时，外层用 h() 组合 button 自行拼装，alert 只负责语义底色
4. 需要用户可自行消除的提示传 closable: true：点 × 淡出并从 DOM 移除，配合 onClose 同步业务状态
5. 有标题与正文两层信息时传 title（加粗首行），正文仍走 message
6. banner: true 出通栏形态（圆角与左右边框归零），页面顶部通知直接铺满容器即可，不需要额外包裹层

现场演示：apps/docs/demo.html#sec-alert

### skeleton — 骨架屏 Skeleton

参数：

- variant（默认 rect）：'text' | 'rect' | 'circle' —— 占位形状；缺省 rect（整块矩形）
- rows（默认 3）：number —— text 形状的行数；缺省 3，末行自动缩短模拟段落

示例：

```
skeleton({ variant: 'text', rows: 3 }); skeleton({ variant: 'circle' }); skeleton({ variant: 'rect' })
```

手把手：

1. 数据加载期间用 skeleton 顶替真实内容，加载完成后整体替换
2. 文本段落用 variant: 'text' + rows 控制行数，头像用 'circle'，卡片/图片区域用 'rect'
3. 占位块自带扫光动画，reduced-motion 用户自动静态化为纯灰块
4. 组合布局：skeleton 是普通 DOM，可放进 card / row 等任意容器

现场演示：apps/docs/demo.html#sec-skeleton

### toast — 消息提示 Toast

参数：

- message（必填）：string —— 提示文案
- duration（默认 3000）：number —— 自动消失毫秒；缺省 3000
- closable（默认 false）：boolean —— 显示关闭按钮，点 × 立即收起；缺省 false（任意 toast 都接收指针，悬停或聚焦即暂停倒计时）
- variant（默认 info）：'info' | 'success' | 'warning' | 'danger' —— 语义色；缺省 info（toast.error 对应 danger）
- actionLabel：string —— 操作按钮文案（如「撤销」）；传了才渲染，点后 toast 立即收起
- onAction（回调）：() => void —— 操作按钮回调，在 toast 收起之后触发（撤销删除之类）

示例：

```
toast.success('保存成功'); toast.error('网络请求失败', 5000)
```

手把手：

1. 直接调用语义工厂：toast.info / success / warning / error(message, duration?)，无需先建容器
2. 不传 duration 时默认停留 3 秒后自动淡出并移除节点
3. 鼠标悬停或键盘 Tab 进卡片时倒计时暂停，移出 / 焦点离开后按剩余时长续走（剩余不足 400ms 时补到 400ms，避免松手即消失）；宿主容器仍穿透，只有卡片本体这一小块可命中
4. 多条消息在右上角自动纵向堆叠，互不遮挡
5. 需要完整入参（含 variant）时用 showToast(props)；组件挂载容器由内部懒创建，样式随 ui.css 一起引入
6. 同屏最多 4 条，超出自动挤掉最早一条；需要用户主动确认的消息用 showToast({ closable: true }) 给个 ×
7. 要「撤销」这类操作时给 showToast 传 actionLabel 与 onAction：按钮出现在文案右侧，点完先收起再回调，duration 建议放到 5000 让用户来得及点
8. 同屏最多 4 条：第 5 条进来时最早一条走正常退场（淡出后移除）而不是瞬间消失；要不被挤掉就传 duration: 0 让它常驻，靠 closable 手动关

现场演示：apps/docs/demo.html#sec-toast

### tooltip — 悬浮提示 Tooltip

参数：

- content（必填）：string —— 提示文案（纯文本）
- placement（默认 top）：'top' | 'bottom' | 'left' | 'right' —— 气泡方位；缺省 top，出场时按视口剩余空间自动翻到对侧（水平方位按宽度判左右）
- delay（默认 150）：number —— 出场延迟毫秒；缺省 150，传 0 立即显示
- trigger（必填）：HTMLElement —— 第二个参数：被包裹的触发器元素（按钮 / 输入框等任意 DOM）

示例：

```
tooltip({ content: '保存后立即生效' }, button({ label: '保存', onClick: save }))
```

手把手：

1. 把提示要挂在的元素作为第二个参数传入，返回的包裹节点替换原位置（气泡绝对定位，不占布局）
2. 鼠标悬停或键盘聚焦后延迟 delay 毫秒出场，移出 / 失焦 / Esc 收起；空间不足时自动翻到另一侧
3. 触摸屏没有 hover：手指按下即出场，抬手后停留 1.2 秒再收起，免得手指正挡着就消失
4. 气泡设了 pointer-events: none，不会挡住下面的点击；aria-describedby 已关联，读屏能念出提示文案
5. 方位支持上下左右四向：贴边或工具栏竖排时用 left / right，出场时按该方向剩余空间自动翻到对侧
6. 出场期间滚动页面或任意滚动容器，气泡会按视口重算上下翻转（rAF 节流，与 select 等浮层共用同一套 trackShift）；收起后监听立即摘掉，不在关闭态空转

现场演示：apps/docs/demo.html#sec-tooltip

### popover — 气泡卡片 Popover

参数：

- content（必填）：unknown —— 弹层内容：字符串 / DOM 节点 / 响应式函数，可放按钮等交互元素
- title：string —— 弹层标题；缺省不渲染标题行
- placement（默认 bottom）：'top' | 'bottom' —— 弹层方位；缺省 bottom（触发器下方）
- trigger（默认 click）：'click' | 'hover' —— 触发方式；缺省 click
- delay（默认 150）：number —— hover 模式出场延迟毫秒；缺省 150
- onOpenChange（回调）：(open: boolean) => void —— 开合回调
- triggerEl（第二个参数）（必填）：HTMLElement —— 第二个参数：被包裹的触发器元素
- open：Signal<boolean> —— 受控开合信号；不传则内部维护，传了外部可编程开合（popconfirm 即基于它）

示例：

```
popover({ title: '筛选条件', content: h('div', null, '任意可交互内容') }, button({ label: '筛选' }))
```

手把手：

1. 创建触发器元素，再用 popover(props, trigger) 包一层：返回带弹层的包裹元素
2. 点击触发器开合；点弹层外部或按 Esc 收起（Esc 后焦点回触发器）
3. content 里可以放 button / checkbox 等交互元素（tooltip 只能纯文本），点击弹层内部不会误关
4. hover 场景传 trigger: 'hover' + delay 控制多快出场；placement: 'top' 让弹层往上翻
5. 需要感知开合做副作用（如懒加载数据）时用 onOpenChange
6. 展开时按 Esc 只收起气泡并回焦触发器，不会连带关闭所在弹窗（逐层关闭）

现场演示：apps/docs/demo.html#sec-popover

### spinner — 加载指示器 Spinner

参数：

- size（默认 18）：number —— 直径像素；缺省 18
- label：string —— 读屏播报文案，如「正在加载订单」；缺省「加载中」

示例：

```
spinner(); spinner({ size: 26 })
```

手把手：

1. 数据加载时插入 spinner()，加载完成移除
2. 用 size 匹配上下文尺寸（按钮内用小号，整页 loading 用大号）
3. 与 skeleton 分工：已知内容结构用 skeleton 占位，只有『在跑』的短等待用 spinner
4. 区块级加载给 spinner 传 label 说明在加载什么，读屏才不会只念一句「加载中」

现场演示：apps/docs/demo.html#sec-feedback

### progress — 进度条 Progress

参数：

- value：Signal<number> —— 当前进度 0-100 的信号；不传即不确定态（未知时长的加载）
- type（默认 line）：'line' | 'circle' —— 形态：线性条或环形；环形需同时传 value（不确定态仍走线性条）
- showText：boolean —— 是否右侧显示百分比；缺省 true（不确定态忽略）
- label：string —— 可访问名，如「上传进度」；缺省不确定态念「进行中」
- variant：'default' | 'success' | 'warning' | 'danger' —— 填充语义色；缺省 default（满格自动切 success）
- format（回调）：(v: number) => string —— 文本自定义：传入则线/环形的进度文本用其返回值替代默认「N%」；缺省取整百分比

示例：

```
const pct = signal(0); progress({ value: pct }); setInterval(() => pct((pct() + 5) % 105), 400)
```

手把手：

1. 传 value 信号给定量进度，不传则退化为往复的不确定态
2. label 写清这是什么进度，读屏会把名称与百分比一起念出来
3. 失败或告警用 variant: 'danger' / 'warning'，该颜色在满格时也不被 success 覆盖
4. 看板 / 上传卡片要环形进度就传 type: 'circle'（同时必须传 value）：SVG 双圆按 dashoffset 出弧，中间自动显示百分比，语义色同 variant

现场演示：apps/docs/demo.html#sec-feedback

### tabs — 标签页 Tabs

参数：

- tabs（必填）：TabItem[] —— 页签列表，每项 { value, label, disabled?, closable? }
- active：Signal<string> —— 当前激活页签 value 信号，双向绑定
- onChange（回调）：(value: string) => void —— 切换回调
- onClose（回调）：(value: string) => void —— 关闭页签回调：组件不自行移除，凭 value 从数据源里摘掉后重绘
- extra：unknown —— 页签栏右侧操作区内容（按钮 / 链接）：与页签同一行、共用一条分隔线；缺省不渲染且页签栏结构不变
- closable：boolean —— 页签项上的开关：true 时渲染关闭按钮（×），关闭按钮不进 Tab 序但可用鼠标点；缺省不可关

示例：

```
const tab = signal('a'); tabs({ tabs: [{ value: 'a', label: '概览', closable: true }, { value: 'b', label: '明细', disabled: true }], active: tab, onClose: (v) => toast.info(`关闭 ${v}`) })
```

手把手：

1. 创建信号 tab = signal('a')，初值为默认页签
2. tabs 只渲染页签头；页签内容用响应式函数按信号切换，如 () => tab() === 'a' ? panelA : panelB
3. 点击或方向键（←→ 环形）、Home/End（直达首/末页签）切换，滑动指示条自动跟随；disabled: true 的页签置灰不可点
4. 页签多到一屏放不下（页签条横向溢出）时，切到被裁掉的页签会自动滚回可视区：只滚页签条本身、不带动页面，系统开启「减弱动效」时直接跳位；已完整可见则不动，不打断用户手动滚动
5. 需要可关闭页签时给该项加 closable: true，× 或聚焦后按 Delete 触发 onClose(value)；组件不改数据源，调用方从数组里摘掉该项再重绘
6. 键盘可访问：Tab 聚焦激活页签，←→ 在启用页签间环形移动，Home/End 跳首个/末个启用页签
7. 页签栏右侧要放「新建 / 设置」这类操作就传 extra（按钮或链接）：它与页签同一行共用一条分隔线，不传时 DOM 结构保持不变

现场演示：apps/docs/demo.html#sec-tabs-modal

### stepper — 步骤条 Stepper

参数：

- steps（必填）：StepperItem[] —— 步骤列表，每项 { label, description? }
- active（必填）：Signal<number> —— 当前步索引（0 基）信号，双向绑定：回跳写回、外部赋值同步
- clickable：boolean —— 已完成步骤是否可点击回跳；缺省 false
- direction（默认 'horizontal'）：'horizontal' | 'vertical' —— 排列方向：horizontal 横排连接线在节点之间；vertical 竖排、连接线落在圆点下方，已走完的一段用主色
- onChange（回调）：(index: number) => void —— 步骤切换回调（点击回跳触发）

示例：

```
const step = signal(1); stepper({ steps: [{ label: '下单' }, { label: '支付' }, { label: '完成' }], active: step, clickable: true })
```

手把手：

1. 用 signal(0) 创建当前步索引，传入 active；索引之前的步骤显示 ✓ 已完成态
2. 流程推进时外部赋值即可：step(step() + 1)，组件三态自动跟随
3. clickable: true 时已完成步骤可点击回跳并写回信号，配合 onChange 同步业务状态
4. 侧栏或移动端传 direction: 'vertical'：圆点成一列、说明文字紧贴右侧，步与步之间由圆点下方的竖线相连（末步不画线，已完成的段用主色标进度）

现场演示：apps/docs/demo.html#sec-stepper

### rate — 评分 Rate

参数：

- value（必填）：Signal<number> —— 当前评分（1 基，allowHalf 时 0.5 步进）信号，双向绑定：点选/键盘写回、外部赋值同步
- max（默认 5）：number —— 星标总数；缺省 5
- allowHalf（默认 false）：boolean —— 允许半星：点击星标左半区记 0.5 分，键盘步进同为 0.5；缺省整数
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onChange（回调）：(value: number) => void —— 评分变化回调
- allowClear：boolean —— 允许清除：再次点击当前分值（半星须精确相等）清零；缺省不可清

示例：

```
const score = signal(0); rate({ value: score, max: 5, onChange: (v) => console.log(v) })
```

手把手：

1. 用 signal(0) 创建评分信号并传入 value；0 表示未评分
2. 点击星标或聚焦后按 →/↑ 加一分、←/↓ 减一分；悬停高亮预览、移出还原
3. max 可调整星标总数；disabled 支持布尔或信号禁用
4. 要支持半星就传 allowHalf：点星标左半区记 0.5 分，悬停位置实时预览半格填充

现场演示：apps/docs/demo.html#sec-rate

### empty — 空状态 Empty

参数：

- title：string —— 主标题；缺省不渲染
- description：string —— 补充说明；缺省不渲染
- icon：unknown —— 图标区内容（字符串或图标 DOM）；缺省渲染 ⌀ 占位符号，装饰性元素不进无障碍树
- action：unknown —— 操作区内容（常见为按钮）；缺省不渲染

示例：

```
empty({ title: '暂无数据', description: '调整筛选条件后再试', action: button({ label: '重置筛选', variant: 'ghost' }) })
```

手把手：

1. 在列表 / 表格 / 筛选结果为空时渲染 empty 占位
2. title 一句话说明状态，description 补充原因或建议
3. action 传入 DOM（如 ghost 按钮）引导用户下一步操作
4. 要换成自家插图就传 icon（字符串或 SVG 节点）：它固定 aria-hidden，文案仍由 title / description 承担

现场演示：apps/docs/demo.html#sec-empty

### divider — 分隔线 Divider

参数：

- label：string —— 居中文案标签；缺省纯线条
- vertical：boolean —— 是否垂直分隔线（用于行内工具栏）；缺省水平
- dashed：boolean —— 是否虚线；缺省实线

示例：

```
divider({ label: '更多信息' }) / divider({ vertical: true })
```

手把手：

1. 在区块之间插入 divider 划分内容层次
2. 传 label 在水平线上显示居中文案，如「更多信息」
3. 工具栏内用 vertical: true 的行内竖线，dashed: true 切虚线弱化分隔感
4. 分割线是纯视觉件：没有 role 也没有焦点，读屏不会播报它。真要让读屏理解分组，用 heading 或 section，别指望横线

现场演示：apps/docs/demo.html#sec-divider

### space — 间距容器 Space

参数：

- direction：'horizontal' | 'vertical' —— 主轴方向；缺省 horizontal 横排
- size：'sm' | 'md' | 'lg' | number —— 子元素间距：三档走 --ua-space-* 令牌，数字按像素；缺省 md
- align：'start' | 'center' | 'end' | 'baseline' —— 交叉轴对齐；缺省 center，按钮与文字混排不错位
- justify：'start' | 'center' | 'end' | 'between' —— 主轴分布；缺省 start，between 两端撑开
- wrap：boolean —— 空间不足是否换行；缺省 false
- block：boolean —— 是否占满父容器宽度；缺省按内容收缩
- split：string —— 子元素之间的分隔符（如 · / |）：纯装饰、aria-hidden，仅 2 个以上子元素时插入

示例：

```
space({ size: 'lg' }, button({ label: '保存' }), button({ label: '取消', variant: 'ghost' })) / space({ direction: 'vertical', align: 'start' }, ...)
```

手把手：

1. 把一排需要统一间距的元素交给 space 托管，不再各自写 margin
2. 调间距：size 传 sm/md/lg 走令牌，需要精确像素时直接给数字
3. 竖排用 direction: 'vertical'，配 align: 'start' 让子元素左对齐
4. 工具栏两端撑开用 justify: 'between' + block: true；窄屏放不下时加 wrap
5. 一行里要塞分隔符（面包屑式「A · B · C」）传 split: '·'：分隔符由容器插入且读屏跳过，不要手写 span 再逐个调 margin

现场演示：apps/docs/demo.html#sec-space

### pagination — 分页器 Pagination

参数：

- page（必填）：Signal<number> —— 当前页码信号，双向绑定：外部赋值自动翻页，点击页码写回信号
- pageSize（必填）：number —— 每页条数
- total（必填）：number | Signal<number> —— 总条数；传信号则总量变化时自动重算页数（并把越界页码钳回并回调 onChange）
- simple（默认 false）：boolean —— 极简模式：只留上一页 / 下一页与「当前 / 总页」文字，适合表格工具栏等窄位
- hideOnSinglePage（默认 false）：boolean —— 只有一页时整个分页器隐藏（不占布局位）
- onChange（回调）：(page: number) => void —— 翻页回调
- jumpable（默认 false）：boolean —— 末尾显示跳页输入框（总页数 > 1 才出现）；缺省 false
- pageSizes：number[] —— 每页条数候选（如 [10, 20, 50]）；传了才渲染条数切换器
- onPageSizeChange（回调）：(size: number) => void —— 切换每页条数回调，同时自动回到第 1 页

示例：

```
const page = signal(1); pagination({ page, pageSize: 3, total: 24, onChange: (p) => console.log(p) })
```

手把手：

1. 创建页码信号 page = signal(1)，与 pageSize / total 一起传入
2. 页码 ≤7 全部展开；更多时只保留首尾与当前页 ±1，缺口折叠为省略号
3. 首末页自动禁用 ‹ › 翻页按钮，当前页禁点并标记 aria-current
4. 与 dataTable 联动：把同一个 page 信号传给 dataTable 的 page 参数，两处翻页自动同步
5. 键盘可达：全部是原生 button，Tab 聚焦后 Enter / Space 触发
6. pageSizes 传候选数组即出现「N 条/页」切换器，切换后自动回第 1 页；onPageSizeChange 拿新条数重算数据
7. 空间紧的地方传 simple：页码区换成「3 / 5」文字 + 两个箭头（文字带 aria-live，翻页会被读屏播报）；数据少到只有一页时再传 hideOnSinglePage，整块自动消失而不是留个灰掉的 1
8. 总量会变（筛选 / 删除后）就把 total 传信号：页码越界时组件自动钳到最后一页并回调 onChange，父级 state 不会停在空页

现场演示：apps/docs/demo.html#sec-pagination

### modal — 弹窗 Modal

参数：

- open（必填）：Signal<boolean> —— 开关信号：true 显示 / false 隐藏
- title（必填）：string —— 标题
- size（默认 md）：'sm' | 'md' | 'lg' —— 宽度档位：sm 340px / md 420px / lg 640px；缺省 md
- footer：unknown —— 底部操作区内容（常见为按钮组，右对齐）；缺省不渲染；给了它就用自定义页脚，内置按钮组不再渲染
- confirmText：string —— 内置页脚：确认按钮文案；缺省「确定」（不传 footer 且有 onConfirm 时也渲染内置页脚）
- cancelText：string —— 内置页脚：取消按钮文案；缺省「取消」，点击走 close 流程
- onConfirm（回调）：() => void | Promise<void> —— 内置页脚：确认回调；返回 Promise 时按钮变「处理中…」并禁用，成功自动收窗、失败留在窗口重试
- closeOnOverlay：boolean —— 点遮罩是否关闭弹窗；缺省 true，表单类可传 false 防误关（× 与 Esc 仍可用）
- onClose（回调）：() => void —— 关闭回调（点遮罩、× 或 Esc 触发）
- ...children：unknown[] —— 弹窗内容子节点

示例：

```
const open = signal(false); modal({ title: '确认操作', open, onClose: () => open(false) }, h('p', null, '内容'))
```

手把手：

1. 创建布尔信号 open = signal(false)，弹窗初始隐藏
2. 把 open 传给 modal，其余参数为弹窗内容子节点
3. 打开：open(true)；关闭：open(false)，点遮罩、× 或 Esc 会自动写回 false；多弹层叠加时 Esc 只关最上层（后开先关）
4. 入场自带遮罩淡入 + 弹窗上浮动画
5. 底部操作按钮放进 footer（如取消/确定），需要更宽内容用 size: 'lg'，窄确认框用 'sm'
6. 打开后 Tab 焦点锁在弹窗内，关闭自动把焦点还给打开前的元素
7. 普通确认框不必手拼 footer：传 confirmText / onConfirm 即得「取消 + 确认」按钮组；onConfirm 写成 async，组件会在等待期间把按钮置灰改文案，resolve 后自己收窗、reject 时保持打开供重试

现场演示：apps/docs/demo.html#sec-tabs-modal

### drawer — 抽屉 Drawer

参数：

- title（必填）：string —— 标题
- open（必填）：Signal<boolean> —— 开关信号：true 滑出 / false 收起
- placement（默认 right）：'left' | 'right' | 'top' | 'bottom' —— 滑出方向；缺省 right
- width（默认 320）：number —— 面板尺寸（像素）：左右滑出为宽度，上下滑出为高度；缺省 320
- footer：unknown —— 底部操作区内容；缺省不渲染
- closeOnOverlay：boolean —— 点遮罩是否关闭抽屉；缺省 true（× 与 Esc 始终可用）
- modal：boolean —— 是否模态：false 时无遮罩、不锁背景滚动、不困焦点，页面仍可操作（检查器 / 详情面板）；缺省 true
- onClose（回调）：() => void —— 关闭回调（点遮罩 / × / Esc 触发）
- ...children：unknown[] —— 面板内容子节点

示例：

```
const open = signal(false); drawer({ title: '详情', open, width: 360, onClose: () => open(false) }, h('p', null, '内容'))
```

手把手：

1. 创建布尔信号 open = signal(false)，传给 drawer；其余参数为面板内容子节点
2. 打开后从侧边滑出：placement: 'right'（默认）或 'left'，width 控制宽度（窄屏自动收到 92vw）
3. 点遮罩、× 或按 Esc 关闭并写回 false；打开时焦点落在面板首个可交互元素，Esc 只关最上层弹层
4. 底部操作按钮放进 footer；与 modal 的分工：modal 是强打断的确认，drawer 保留主视图上下文做详情/编辑
5. 面板内容与 modal 一样接受任意 DOM 与响应式函数
6. 要「一边看一边改」就把 modal 设成 false：遮罩透明且不吃点击、背景滚动不锁、Tab 不被困在面板里，× 与 Esc 仍能关

现场演示：apps/docs/demo.html#sec-drawer

### form — 表单 Form / 表单字段 FormItem

参数：

- layout（默认 vertical）：'vertical' | 'horizontal' —— 布局方向；缺省 vertical（horizontal 为标签左置）
- columns（默认 1）：1 | 2 | 3 —— 字段列数：>1 时字段走栅格铺列（沿用 grid 的窄屏单列），按钮区始终整行；缺省 1
- submitText：string —— 提交按钮文案；缺省「提交」
- resetText：string —— 重置按钮文案；缺省不渲染
- onSubmit（回调）：() => void —— 全部字段校验通过后的提交回调
- （formItem）label（必填）：string —— 字段标签
- （formItem）value（必填）：Signal<string> —— 字段值信号，校验数据源
- （formItem）required：boolean —— 必填：显示红星并自动追加非空规则
- （formItem）rules：FormRule[] —— 校验规则：required / min / max / pattern / validator，按序执行取首个失败
- （formItem）validateOn（默认 blur）：'input' | 'blur' | 'submit' —— 校验时机；缺省 blur（输入时自动清已报错误）
- （formItem）hint：string —— 控件下方辅助说明（格式提示等）；报错时让位给错误文案
- （formItem）span：number —— 多列表单里本字段占几列（如整宽的多行备注）；单列布局或窄屏下自动退回整行
- （formItem）visible：Signal<boolean> —— 条件显示信号：为假时整项隐藏且不参与校验（未显示的字段不该拦住提交）
- onReset：() => void —— 重置按钮点击回调；组件不自行清空字段，由调用方重置信号；不传 resetText 不渲染按钮
- onError（回调）：(count: number) => void —— 校验不通过时的回调，参数为不通字段数（适合埋点或顶部提示）
- errorSummary：boolean —— 是否在表单顶部显示「有 N 项需要修改」汇总条；缺省 true，自带错误 UI 时传 false

示例：

```
const name = signal(''); const f = form({ onSubmit: submit }, [formItem({ label: '用户名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }))])
```

手把手：

1. 为每个字段创建 signal 与控件，如 const name = signal(''); textField({ value: name })
2. 用 formItem 包住控件：formItem({ label, required, value, rules }, control)，返回 { el, validate } 句柄
3. 把句柄数组交给 form：form({ onSubmit }, items)，form 渲染全部字段 + 提交/重置按钮
4. 提交时逐项校验：全过触发 onSubmit；有错则标红控件、显示错误文案并聚焦首个出错项
5. 规则语义：required 非空（trim）、min/max 长度、pattern 正则、validator 自定义（true 通过 / 字符串为错误）
6. 非必填字段为空时跳过其余规则；Enter 键触发表单原生 submit（已拦截走校验流程）
7. 错误态覆盖包裹式控件：标红作用于控件根与内部原生控件（含自绘 select 的触发器），并同步 aria-invalid，读屏与视觉一致
8. 触发时机用 input / change / focusout（均会冒泡），因此 checkbox / select / 单选组这类包裹式控件同样能失焦校验
9. 单行输入框内按 Enter 等同点击提交（textarea 内仍是换行）；提交按钮为 type="submit"，不会与点击回调重复触发
10. rules.validator 返回 Promise 即为异步规则（如「邀请码是否已被使用」远程校验）：提交时逐项 await，校验期间提交按钮禁用，结论按轮次取舍避免乱序覆盖
11. formItem 返回 { el, validate, validateAsync } 句柄：validate 只跑同步规则（供 blur/input 即时反馈），validateAsync 逐条 await 供 form 聚合提交
12. 需要给字段加格式说明就传 hint：平时显示在控件下方，校验失败时自动让位给错误文案，同一位置不堆两行
13. 字段多的编辑页传 columns: 2 或 3 走多列栅格：列间距与窄屏堆叠沿用 grid，按钮区始终整行；单列表单不要传（默认 1）
14. 多列表单里想整宽就传 span：formItem({ span: 2 }, textarea(...)) 会占满两列，窄屏单列时自动忽略跨列（否则会撑出横向滚动）
15. 字段要按另一个字段的值决定是否出现（选了「其他」才出现输入框），给 formItem 传 visible 信号：隐藏项自动清掉错误态并退出校验，不用自己在 items 数组里增删
16. 校验失败必定中止提交：判据是错误条数而不是「有没有找到焦点目标」，所以只有自绘控件（非搜索式下拉 / 评分 / 开关，容器里没有原生 input）的字段也能拦住；焦点定位顺序是控件 → 触发器 → 字段容器（后者临时 tabindex=-1，只接受程序聚焦，不污染 Tab 序）

现场演示：apps/docs/demo.html#sec-formlab

### dataTable — 数据表格 DataTable

参数：

- data（必填）：T[] | Signal<T[]> —— 全量行数据（行须含 id 作 rowKey）；传信号或 computed 时数据变化自动换源，排序 / 筛选 / 页码保持用户原意
- columns（必填）：ColumnDef<T>[] —— 列定义：内核字段 { key, header?, sortable?, width? } + 皮肤字段 { align?, format? }；width 数字为固定像素，'flex' 自适应
- filterKey：string —— 工具栏筛选的列；缺省取第一列
- pageSize（默认 5）：number —— 每页行数；缺省 5
- pageSizes（默认 [5, 10, 20]）：number[] —— 分页条「每页条数」候选档；缺省 [5, 10, 20]，当前 pageSize 不在候选里会自动补进去
- emptyText（默认 暂无数据）：string —— 无数据时的空状态文案；有筛选词时仍显示「没有符合「x」的行」并给一键清除（两种语义不混用）
- selectable（默认 false）：boolean —— 启用行选择列：全选三态（含半选）、行 checkbox、已选计数；缺省 false
- page：Signal<number> —— 受控页码信号：与内核分页双向同步，传同一个信号给独立 pagination 组件即可联动
- loading：boolean | Signal<boolean> —— 加载态：为真时数据行换成骨架行（行数随 pageSize），筛选输入与翻页按钮临时禁用，容器带 aria-busy
- selected：Signal<RowKey[]> —— 受控选中行信号：与内核选择双向同步（内容比较，不来回派发），供批量操作读取/改写
- exportable：boolean —— 工具栏显示「导出 CSV」：导出筛选 + 排序后的全量行（不分页），列跟随当前可见列；缺省 false
- exportName：string —— 导出文件名；缺省 table.csv
- onRowClick（回调）：(row: T) => void —— 整行点击 / Enter / 空格的回调；传了行才可聚焦可点（未传时行保持纯展示，不抢 Tab 焦点）
- columnToggle（默认 false）：boolean —— 工具栏显示「列」按钮：弹层内勾选控制列显隐，至少保留一列（最后一列的勾选框锁死），缺省 false
- hiddenColumns：Signal<string[]> —— 受控隐藏列键集：与「列」面板双向同步，外部可据此持久化用户偏好；隐藏列不渲染、不进导出

示例：

```
dataTable({ data: users, selectable: true, columns: [{ key: 'name', header: '姓名', sortable: true }], pageSize: 5 })
```

手把手：

1. 准备行数据，确保每行有唯一 id（跨页选择与刷新不丢行靠它）
2. 定义 columns：sortable: true 的列表头可点击排序（升 → 降 → 取消循环），标记箭头 ↑↓↕
3. 多列排序：按住 Shift 点第二个可排序列即叠加——内核按 rules 顺序逐列比较，表头标记带优先级（↑1 / ↓2），不按 Shift 则回到单列语义
4. 列展示细节用皮肤字段：align 控制表头与单元格对齐，format(value, row) 返回纯文本（走 textContent，不需要转义）
5. 数据源可以给信号或 computed（如筛选结果）：变化时内核整体换源，选择状态按 rowKey 剔除已消失的行，排序与页码不被打断
6. 列宽用内核字段 width：数字（像素）固定该列，'flex' 或不设则按内容自适应
7. selectable: true 开启行选择：表头全选框带 indeterminate 半选态，工具栏显示已选计数
8. 选中行用低视觉比重样式（淡色底 + 弱化文字），选择状态存 rowKey，跨页不丢失
9. 工具栏输入框即筛选，输入即时过滤并回第 1 页；无匹配时显示空状态行
10. 表头粘性固定（滚动容器 max-height 420px），斑马纹 + 悬停高亮由 CSS 提供
11. 数据在途时传 loading（布尔或信号）：表格体换成骨架行、筛选与翻页禁用，返回后置回 false 即恢复
12. 分页条自带每页条数下拉（5 / 10 / 20 条，调用方传的 pageSize 若不在候选里会自动补进去）：切换即改页大小并回第 1 页，首末页时上/下一页置灰不再派发无效页码
13. 需要独立分页器联动时传 page 信号：const page = signal(1)，dataTable({ page }) 与 pagination({ page }) 共用
14. 需要批量操作时传 selected 信号：内部勾选实时写回，外部置空或赋值即同步整表（跨页保留，按 rowKey 存）
15. 可排序表头的 aria-sort 跟随排序态同步（ascending / descending / none），读屏能直接播报当前列序，箭头只是视觉补充
16. 需要导出时开 exportable：按钮导的是筛选+排序后的全量行，与当前页无关，单元格按 format 输出
17. 整行可点（进详情、选中一行做后续操作）传 onRowClick：行会变成可聚焦项，Enter / 空格与点击等价；不传时行不进 Tab 序列，纯展示表不会被读屏当成按钮
18. 需要让用户自己调列时开 columnToggle：工具栏多一个「列」按钮，弹层勾选即刻重绘表头与单元格（隐藏列不占 colspan，空状态与骨架行同步收窄）
19. 想记住用户偏好就传 hiddenColumns 信号：面板勾选实时写回，外部赋值即整表跟随；隐藏筛选列后筛选条件仍然生效，属预期而非失效

现场演示：apps/docs/demo.html#sec-table

### timeline — 时间线 Timeline

参数：

- items（必填）：TimelineItem[] —— 条目列表，按时间顺序自上而下；每项含 title（必填）、content、time、variant
- mode（默认 left）：'left' | 'right' | 'alternate' —— 轴线位置：left / right 单侧排布；alternate 双侧交替（轴线居中、奇偶左右分列，窄屏自动退回左侧）
- pending（默认 false）：boolean —— 末尾追加虚线「进行中…」节点，表示流程尚未结束；缺省 false

示例：

```
timeline({ items: [{ title: '提交审批', time: '09:12', variant: 'success' }, { title: '财务复核', time: '10:30', content: '等待张工处理' }], pending: true })
```

手把手：

1. 事件流按时间顺序排（组件不做排序）：较早的条目放前面，最新的放最后
2. variant 只承载语义：success 完成、warning 待处理、danger 失败、default 普通记录（节点色走 --ua-* 令牌，随暗色主题变化）
3. time 是纯展示文案，格式自由（'09:12' / '10-23 18:00'），组件不做日期解析
4. 流程未结束时传 pending: true，末尾自动补一个虚线脉冲节点，完成后置回 false 即消失
5. 需要轴线居右（如右侧栏时间流）时传 mode: 'right'，条目与节点整体镜像
6. content 为纯文本；需要按钮等交互内容时改用 accordion / card 组合
7. 要做里程碑式双侧时间线就传 mode: 'alternate'：轴线居中、条目按奇偶分列左右并在窄屏退回单侧，pending 虚节点同样参与交替计数

现场演示：apps/docs/demo.html#sec-timeline

### segmented — 分段控制器 Segmented

参数：

- options（必填）：OptionItem[] —— 分段选项列表（互斥），每项含 value / label / disabled
- value（必填）：Signal<string> —— 当前选中值信号，双向绑定（点击写回，外部赋值即同步滑块）
- size（默认 md）：'sm' | 'md' —— 尺寸档位；缺省 md
- disabled：boolean | Signal<boolean> —— 整组禁用：布尔直接置位，传信号则动态同步；单项禁用走 options[].disabled
- onChange（回调）：(value: string) => void —— 切换回调（禁用项与重复点击当前项都不触发）

示例：

```
segmented({ options: [{ value: 'day', label: '日' }, { value: 'week', label: '周' }], value: range })
```

手把手：

1. 和 tabs 的分工：tabs 是页签（下划线指示条 + 面板语义），segmented 是紧凑互斥切换（视图 / 单位 / 时间粒度）
2. value 必须传信号：点击写回信号，外部改信号滑块自动平移，不要在回调里另存一份状态
3. options[].disabled 置灰单项；disabled 传布尔或信号则整组失效（连滑块一起置灰）
4. 键盘：←→ 在可用项间环形移动并选中，Tab 只落在当前项上（漫游 tabindex）
5. 滑块几何由组件测量按钮位置得到，不要在外部改按钮宽度 / 内边距，否则需自行重渲染
6. 需要带图标或更复杂布局的分段，改用 radioGroup + 自定义样式

现场演示：apps/docs/demo.html#sec-segmented

### descriptions — 描述列表 Descriptions

参数：

- items（必填）：DescriptionItem[] —— 描述项列表：每项含 label（字段名）、value（字符串 / DOM / 响应式函数）、span（跨列数）
- column（默认 3）：1 | 2 | 3 | 4 —— 桌面列数；缺省 3，窄屏（≤640px）由样式表降为单列
- bordered（默认 false）：boolean —— 表格形态（带分隔线与字段名底纹）；缺省 false 纯网格
- size（默认 md）：'sm' | 'md' —— 尺寸档位；缺省 md
- title：string —— 分组标题；缺省不渲染标题行
- layout（默认 'horizontal'）：'horizontal' | 'vertical' —— 字段名位置：horizontal 在值左侧，vertical 在值上方（窄栏 / 长文本更耐读，配 bordered 时去掉标签区的分隔线）
- extra（回调）：unknown —— 标题行右侧内容（如更新时间、刷新按钮）；仅在传了 title 时渲染

示例：

```
descriptions({ title: '订单信息', column: 2, bordered: true, items: [{ label: '订单号', value: 'RB-2048' }, { label: '金额', value: '¥1,280.00' }, { label: '备注', value: '加急', span: 2 }] })
```

手把手：

1. 详情页 / 抽屉里展示只读字段用它，不要用 form（form 承载输入与校验）
2. column 按信息密度定：字段多且短用 3-4 列，字段长（地址、备注）用 2 列并配 span 占整行
3. value 支持任意 DOM：放 tag、badge、button 都可以；要给值做响应式更新就传函数 () => 内容
4. bordered: true 是表格形态（适合打印与密集信息），纯网格形态更轻，适合卡片内嵌
5. 窄屏自动降为单列，无需在调用方写媒体查询
6. 纯展示组件，无状态无回调：值变了由调用方重新渲染或传函数式 value
7. 窄栏或长文本场景把 layout 设成 'vertical'：字段名落到值上方，bordered 形态会自动去掉标签右侧的分隔线与底纹；标题行要放操作就传 extra（数组可放多个节点）

现场演示：apps/docs/demo.html#sec-descriptions

### statistic — 统计数值 Statistic

参数：

- title：string —— 指标名（小字灰标题）；缺省不渲染
- value（必填）：Signal<number> —— 数值信号：变化时滚动过渡到新值；倒计时模式可不传
- countdown：number —— 目标时间戳（毫秒）：传了即倒计时模式，每秒显示 DD 天 HH:MM:SS，忽略 value
- precision（默认 0）：number —— 小数位，千分位分隔自动带；缺省 0
- prefix：string —— 数字前缀（如 ¥）；缺省不渲染
- suffix：string —— 数字后缀（如 元 / %）；缺省不渲染
- trend：'up' | 'down' —— 环比方向：up 红 / down 绿（A 股涨红跌绿口径）；缺省不渲染

示例：

```
const gmv = signal(128000); statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' })
```

手把手：

1. value 必须传信号：组件用 effect 监听，值变了自动滚动过渡，不要在外部手动改文本
2. 首跑也会从 0 滚到目标值（入场动画）；不想要就初始化信号为终值后在挂载后再改
3. precision 控制小数位，千分位由 toLocaleString 自动加，不要自己拼格式化
4. trend 只表达方向与颜色（涨红跌绿），数值本身与环比幅度仍由调用方组织
5. 多条指标并排时用 descriptions 或 flex 容器排列，本组件不自带网格
6. 用户开启「减弱动效」时组件直接落终值，无需调用方判断
7. 做倒计时（活动剩余时间等）传 countdown: 目标时间戳，组件每秒刷新并在到点/卸载时自动停表

现场演示：apps/docs/demo.html#sec-statistic

### list — 列表 List

参数：

- items（必填）：ListItem[] —— 条目列表，每项 { title, description?, avatar?, extra? }
- header：string —— 列表头标题；缺省不渲染
- footer：string —— 列表尾说明；缺省不渲染
- bordered：boolean —— 是否带外边框与分隔线；缺省 false
- emptyText：string —— 无数据占位文案；缺省「暂无数据」
- onItemClick（回调）：(item: ListItem, index: number) => void —— 条目点击回调；提供后条目可点击并带 hover 反馈

示例：

```
list({ header: '待办', bordered: true, items: [{ title: '报销单 RB-2048', description: '等待复核', avatar: avatar({ name: '王' }) }], onItemClick: (item) => console.log(item.title) })
```

手把手：

1. 准备 items 数组，每项至少给 title，description 作次要说明
2. avatar 传头像组件（如 avatar({ name })）、extra 传右侧附加（如 tag(...)），不传该侧不留位
3. 需要点击时传 onItemClick(item, index)，条目自动加 hover 底色；bordered: true 给外边框与分隔线
4. items 为空数组时自动渲染 empty 占位，文案可用 emptyText 覆盖

现场演示：apps/docs/demo.html#sec-list

### carousel — 轮播 Carousel

参数：

- items（必填）：unknown[] —— 每张幻灯片内容，顺序即播放顺序
- index：Signal<number> —— 当前下标信号；不传则内部维护
- autoplay：number —— 自动前进间隔（毫秒）；缺省不自动播放
- height：number —— 固定高度像素；缺省由内容撑开
- label：string —— 区域无障碍名称；缺省「轮播」
- onChange（回调）：(index: number) => void —— 切换回调：点击指示点/箭头/键盘/自动前进都会触发

示例：

```
const i = signal(0); carousel({ items: [h('div', null, 'A'), h('div', null, 'B')], index: i, height: 150, autoplay: 4000, onChange: (k) => console.log(k) })
```

手把手：

1. items 每项是任意节点（图、卡片、文字均可），顺序即播放顺序
2. index 传自己的信号即可外部受控（外部改信号轮播会跟），不传则由组件内部维护
3. height 控固定高度（缺省随内容撑开）；autoplay 传毫秒数自动前进，鼠标悬停自动暂停
4. 键盘：聚焦轮播后 ←→ 切换，事件不冒泡避免连带切换外层 tabs/menu
5. 指示点与箭头都可点击，末张下一张环形回到首张

现场演示：apps/docs/demo.html#sec-carousel

### tree — 树形 Tree

参数：

- nodes（必填）：TreeNode[] —— 根节点列表，每项 { key, label, children?, disabled? }
- expanded：Signal<string[]> —— 展开的 key 集合；不传则内部维护
- selected：Signal<string> —— 选中 key 信号；不传则内部维护
- defaultExpandAll：boolean —— 首屏展开全部分支；缺省 false
- searchable：boolean —— 顶部搜索框：按标签过滤，命中节点保留父链并自动展开；缺省 false
- onSelect（回调）：(node: TreeNode) => void —— 选中回调：点击标签或 Enter/空格触发
- multi：boolean —— 是否显示勾选框并开启多选；缺省 false
- checked：Signal<string[]> —— 勾选 key 集合（multi 时生效）；不传则内部维护
- onCheck：(keys: string[]) => void —— 勾选变更回调（父子已级联，传副本）

示例：

```
const sel = signal(''); tree({ nodes: [{ key: 'src', label: 'src', children: [{ key: 'ui', label: 'ui' }] }], selected: sel, onSelect: (n) => console.log(n.key) })
```

手把手：

1. 节点树用 { key, label, children } 描述，key 唯一即可（选中与展开都按 key 记）
2. 传 selected 信号即可外部受控；不传则组件内部维护选中态
3. 点箭头只展开/收起，点标签才选中；叶子节点箭头位置留空以对齐
4. 键盘：↑↓ 在可见节点间移动、Home/End 到首末行（只移动焦点），Enter/空格 选中、→ 展开、← 收起；disabled 节点点击与键盘都不响应
5. defaultExpandAll 首屏展开全部分支（外部已给 expanded 初值时以外部为准）
6. multi: true 时每行前置勾选框：勾父级下钻全选子孙（跳过禁用项），子孙全选后父级自动上勾、部分勾选显示半选
7. 勾选框点击不触发选中（stopPropagation），键盘在多选模式下 Enter/空格改为切换勾选
8. 节点多时开 searchable：输入即过滤，命中节点连同父链一起显示，隐藏的枝不参与方向键遍历

现场演示：apps/docs/demo.html#sec-tree

### upload — 文件选择 Upload

参数：

- accept：string —— 接受的类型（同原生 accept），如 'image/*,.pdf'
- multiple：boolean —— 允许多选；缺省 false（单选时新选择覆盖旧的）
- hint：string —— 提示文案；缺省「点击或拖拽文件到此处」
- maxSize：number —— 单文件体积上限（字节）；超限丢弃并在提示行说明，缺省不限
- maxCount：number —— multiple 时的文件数上限；超出部分丢弃并提示，缺省不限
- disabled：boolean —— 禁用：不可点选、不接受拖拽
- onChange（回调）：(files: File[]) => void —— 文件变化回调：选择/拖拽/移除后都触发，回传副本

示例：

```
upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onChange: (files) => console.log(files.map((f) => f.name)) })
```

手把手：

1. 把 upload 放进表单，accept 限定类型、multiple 允许多选
2. 需要拦体积就给 maxSize（字节），限制数量给 maxCount
3. 被拒的文件不会进清单，原因显示在下方提示行（拖拽与点选走同一套校验）
4. onChange 拿到的 File[] 是副本，自行发请求上传（组件不内置网络层）
5. 选中的图片会带 28px 缩略图（对象 URL，随文件离场即 revoke，不会泄漏）；非图片只显示文件名与体积，点 × 移除

现场演示：apps/docs/demo.html#sec-upload

### notification — 通知卡片 Notification

参数：

- title（必填）：string —— 通知标题
- description：string —— 补充说明；缺省不渲染
- variant：'info' | 'success' | 'warning' | 'danger' —— 语义色（左侧条与圆点）；缺省 info
- duration：number —— 自动关闭毫秒；缺省 0 = 常驻不自动关，非 0 时悬停 / 聚焦暂停计时
- closable：boolean —— 是否显示关闭按钮；缺省 true
- action：unknown —— 操作区内容（如按钮）；缺省不渲染
- placement（默认 bottom-right）：'top-right' | 'bottom-right' | 'top-left' | 'bottom-left' —— 停靠角落；每个角落独立堆叠（同角最多 3 条），右上与 toast 同侧，建议次要信息放左侧
- key：string —— 同 key 复用同一张卡：再次调用只原地改标题 / 描述 / 语义色，不再堆新条（返回的句柄带 update 方法可主动改）

示例：

```
const { close } = notification({ title: '导出完成', description: 'report.csv 已开始下载', variant: 'success', duration: 4000 }); // close() 手动关闭
```

手把手：

1. 调用即入列：右下角堆叠，同屏最多 3 条，超出自动挤掉最早一条
2. 缺省常驻（duration 0），需要自动消失就传毫秒数，如 duration: 4000
3. 返回 { el, close } 句柄：程序化关闭用 close()，需要自己挂交互就用 el
4. action 放操作区（常见为按钮）；variant 决定左侧条与圆点语义色
5. 与 toast 的分工：toast 右上角轻量瞬时，notification 右下角可长读、可操作
6. 停靠角落用 placement 调：四个角各自独立堆叠与节流，顶部角落新通知从屏幕边缘长出、旧的往下让
7. 进度类通知传 key: 'sync-job'：第一次返回句柄，后续 job.update({ title, description, variant }) 就地把同一条卡改成 60% / 完成，不会像以前那样堆出三条；job.close() 收掉时同时释放 key 登记

现场演示：apps/docs/demo.html#sec-notification

### result — 结果页 Result

参数：

- status：'success' | 'warning' | 'danger' | 'info' —— 终态语义色（图标底色）；缺省 info
- title（必填）：string —— 主标题
- description：string —— 补充说明；缺省不渲染
- extra：unknown —— 操作区内容（常见为主次按钮）；缺省不渲染

示例：

```
result({ status: 'success', title: '提交成功', description: '工单 #2049 已受理', extra: button({ label: '查看工单', variant: 'primary', onClick: () => go() }) })
```

手把手：

1. status 决定图标底色（success 绿 / warning 黄 / danger 红 / info 蓝），整体居中排布
2. title 必填；description 用于说明原因或下一步，超过一行自动居中换行
3. extra 放操作区，常见为主按钮 + 幽灵按钮；不传则不渲染
4. 组件自带 role="status"，插入页面后读屏会播报终态文案

现场演示：apps/docs/demo.html#sec-result

### transfer — 穿梭框 Transfer

参数：

- dataSource（必填）：TransferItem[] —— 全量条目，左右两栏都按是否在 targetKeys 里派生
- targetKeys：Signal<string[]> —— 右侧集合信号；不传则组件内部维护，传了即外部受控
- titles：[string, string] —— 左右栏标题；缺省「待选列表 / 已选列表」
- searchable：boolean —— 是否显示搜索框；缺省 true，逐栏独立过滤
- height：number —— 列表区固定高度像素；缺省走样式表 220，超出滚动
- onChange：(keys: string[]) => void —— 右侧集合变化回调（搬运后触发，传副本）

示例：

```
transfer({ targetKeys: enabled, titles: ['待选', '已启用'], onChange: (keys) => toast.info(`已启用 ${keys.length} 项`), dataSource: [{ key: 'a11y', label: '无障碍语义' }, { key: 'ssr', label: 'SSR（暂不可用）', disabled: true }] })
```

手把手：

1. dataSource 是唯一数据源，条目归属由 targetKeys 决定；搬运只改 targetKeys，不删条目
2. 两栏独立勾选，勾选后点中间 → / ← 整体搬运，搬完两栏勾选自动清空；无勾选时按钮自动禁用
3. 每栏均有搜索框与全选：全选只覆盖当前搜索结果里未禁用的条目，部分勾选时全选框显示半选
4. disabled 条目不可勾选、不计入全选基数、也不会被搬运
5. 传入 targetKeys 信号即为受控用法，外部写入即可程序化搬运（如「一键全选」）

现场演示：apps/docs/demo.html#sec-transfer

### cascader — 级联选择 Cascader

参数：

- options（必填）：CascaderOption[] —— 根级选项，children 递归出列，value 逐级拼成路径
- value：Signal<string[]> —— 选中路径信号；不传则内部维护，传了即外部受控
- placeholder：string —— 未选时占位文案；缺省「请选择」
- separator：string —— 路径分隔符；缺省「 / 」
- changeOnSelect：boolean —— 允许停在中间层；缺省 false 必须选到叶子才提交
- disabled：boolean | Signal<boolean> —— 禁用触发器；支持信号响应式禁用
- onChange：(path: string[]) => void —— 选中回调（叶子或中间层，传副本）

示例：

```
cascader({ value: areaPath, changeOnSelect: true, options: [{ value: 'east', label: '华东', children: [{ value: 'sh', label: '上海' }] }] })
```

手把手：

1. 传入嵌套 options，第一列是根级选项，悬停带 children 的项即展开下一列
2. 点击叶子提交；changeOnSelect 为 true 时点中间层也提交（适合「省/市」这类粗粒度就够用的场景）
3. value 是路径数组（逐级 value），传信号即受控；触发器文案按 separator 拼接已提交路径
4. 键盘：↑↓ 同列移动、→ 下钻、← 回退、Enter/空格 提交、Esc 收起（与弹窗共用一个 Esc 栈）
5. 面板空间不足时自动向上翻转；disabled 选项不可点也不会展开下一列

现场演示：apps/docs/demo.html#sec-cascader

### splitter — 可拖拽分栏 Splitter

参数：

- panes（必填）：unknown[] —— 各栏内容（节点或纯文本），顺序即排列顺序
- direction：'horizontal' | 'vertical' —— 分割方向；缺省 horizontal（左右）
- sizes：Signal<number[]> —— 各栏百分比数组（和为 100）；不传则内部均分
- min：number —— 每栏最小百分比；缺省 10，拖到边界即停
- onChange：(sizes: number[]) => void —— 拖拽 / 键盘调整后回调（传副本）

示例：

```
splitter({ sizes: ratio, min: 15, panes: [leftPanel, rightPanel], onChange: (s) => ratio([...s]) })
```

手把手：

1. panes 长度决定栏数与分隔条数（n 栏对应 n-1 条），栏宽由 sizes 百分比驱动
2. 指针按住分隔条拖动即可改比例，拖动过程两栏之和恒定、各自不低于 min
3. 分隔条可 Tab 聚焦，← → / ↑ ↓ 每次调 2%，按住 Shift 调 10%；双击复位为均分
4. direction 传 vertical 变为上下分栏，键盘方向键与光标同步切换
5. 传入 sizes 信号即受控，外部写入可做「预设布局」切换（如 1:1 / 2:1）

现场演示：apps/docs/demo.html#sec-splitter

### autoComplete — 自动补全 AutoComplete

参数：

- options（必填）：AutoCompleteOption[] | ((q: string) => AutoCompleteOption[]) —— 候选项：静态列表或按关键字计算的函数（可远程联想）
- value：Signal<string> —— 输入值信号；不传则内部维护，传了即外部受控
- placeholder：string —— 占位文案，同时作为无障碍名称
- emptyText：string —— 无匹配时的提示文案；缺省「无匹配项」
- maxItems：number —— 最多展示条数；缺省 8
- disabled：boolean | Signal<boolean> —— 禁用输入与弹层
- onInput：(query: string) => void —— 每次输入回调，适合触发远程请求
- onSelect：(option: AutoCompleteOption) => void —— 选中回调（点击或 Enter）

示例：

```
autoComplete({ value: acValue, placeholder: '输入组件名', options: [{ value: 'dataTable', hint: '表格' }], onSelect: (o) => toast.success(o.value) })
```

手把手：

1. 输入任何字符即按包含匹配（忽略大小写）过滤候选，空串时弹层不展开
2. ↑↓ 在候选间环形移动并高亮，Enter 选中，Esc 收起，Tab 失焦也会收起
3. 候选行支持右侧 hint（分类 / 拼音），选中后值写回输入框并触发 onSelect
4. options 传函数时每次输入都会用当前关键字调用，可在此发起远程请求并返回新数组
5. value 传信号即受控：外部写入会同步回填输入框内容

现场演示：apps/docs/demo.html#sec-autocomplete

### datePicker — 日期选择 DatePicker

参数：

- value：Signal<string> —— 选中日期（YYYY-MM-DD）信号；区间模式下是起始日，不传则内部维护
- range（默认 false）：boolean —— 区间模式：首次点击定起点（弹层不收起），再次点击定终点；点早了自动交换先后
- end：Signal<string> —— 区间模式的结束日信号；不传则内部维护
- onRangeChange（回调）：(start: string, end: string) => void —— 区间两端都选定后的回调，参数已按先后排序（单日模式不触发）
- placeholder：string —— 未选时占位文案；缺省「请选择日期」（区间模式缺省「请选择日期范围」）
- min：string —— 最早可选日期（含，YYYY-MM-DD）；更早的日置灰不可点
- max：string —— 最晚可选日期（含，YYYY-MM-DD）；更晚的日置灰不可点
- disabled：boolean | Signal<boolean> —— 禁用触发器
- presets：DatePreset[] —— 快捷区间按钮组（仅区间模式渲染）：每项给 label + days（相对今天含今天），或 label + from/to 绝对日期；越界结果按 min/max 钳制，点击即写入 value/end 并收起
- onChange：(date: string) => void —— 选中回调（点击日期或 Enter）

示例：

```
datePicker({ value: dateVal, min: '2026-10-20', max: '2026-10-31', onChange: (d) => toast.success(d) })
```

手把手：

1. 点触发器展开当月网格，打开时视图自动对准已选日期（无选中则今天）
2. ‹ › 翻月，底部「今天」一键跳今天并提交；今天用描边标记、选中用实心底色
3. 键盘：←→ 逐日、↑↓ 逐周、PageUp/PageDown 逐月、Enter 提交，跨月会自动翻页
4. min / max 为闭区间，越界日期置灰不可点也不可键盘选中
5. value 传信号即受控；Esc 收起（与弹窗/抽屉共用逐层关闭栈）
6. 选日期区间传 range: true：第一次点击只定起点（触发器显示「起 ~ …」），第二次点击补终点并收起，两端之间用低比重底色连成一条
7. 区间回调走 onRangeChange(start, end)，参数已排序——用户先点晚的日期也不会出现 start > end；只需检视当前值就同时读 value 与 end 两个信号
8. 传 presets 在弹层顶部加一排快捷区间按钮：{ label: '最近 7 天', days: 7 } 走相对今天，{ label: '本月', from: '2026-10-01', to: '2026-10-31' } 走绝对日期（两者都给时 days 优先），点一下即写入并收起，回调照旧走 onRangeChange

现场演示：apps/docs/demo.html#sec-datepicker

### backTop — 返回顶部 BackTop

参数：

- target：HTMLElement | Window —— 监听的滚动容器；缺省 window（整页）
- threshold：number —— 滚动超过多少像素才浮现；缺省 300
- progress：boolean —— 是否显示滚动进度环；缺省 true
- onClick：() => void —— 回顶之后的回调（适合埋点）

示例：

```
backTop({ threshold: 300, onClick: () => toast.info('已回到顶部') })
```

手把手：

1. 把返回的元素挂到页面任意位置即可，组件自身 position: fixed 定位在右下角
2. 滚动越过 threshold 才浮现（淡入 + 上移微动画），回到阈值内自动隐藏
3. progress 为 true 时外环弧长等于已滚比例；内滚容器用 target 传入该元素
4. 点击平滑回顶；系统开启「减少动效」时改为瞬间跳转

现场演示：apps/docs/demo.html#sec-backtop

### colorPicker — 颜色选择 ColorPicker

参数：

- value：Signal<string> —— 颜色值信号（CSS 颜色串，支持 var(--ua-*)）；不传则内部维护
- presets：string[] —— 预设色板；缺省取语义令牌色（accent/success/warning/danger/text/border）
- placeholder：string —— 未选时占位文案；缺省「请选择颜色」
- disabled：boolean | Signal<boolean> —— 禁用触发器
- onChange：(color: string) => void —— 选定回调（点色板、取色器或手输回车）

示例：

```
colorPicker({ value: colorVal, onChange: (c) => toast.success(c) })
```

手把手：

1. 点触发器展开弹层：上方是预设色板，下方是原生取色器 + 手输框
2. 预设缺省直接用 --ua-* 令牌色，所以暗色主题切换后色块自动跟着变，无需维护两套色值
3. 点色块即提交；手输框支持任意 CSS 颜色串（含 var(--ua-color-accent)），回车提交
4. 触发器左侧色块始终预览当前值，点击外部或 Esc 收起

现场演示：apps/docs/demo.html#sec-colorpicker

### tour — 引导漫游 Tour

参数：

- steps（必填）：TourStep[] —— 步骤序列：每步含 target 选择器、title、可选 description
- padding：number —— 聚光区相对目标的外扩像素；缺省 6
- onFinish：() => void —— 走完最后一步的回调
- onClose：() => void —— 中途跳过（× 或 Esc）的回调

示例：

```
const t = tour({ steps: [{ target: '#sec-tour', title: '引导漫游', description: '按选择器定位' }], onFinish: () => toast.success('完成') }); document.body.append(t.el); t.start();
```

手把手：

1. tour() 返回 { el, start, close } 句柄：el 必须挂到 body（fixed 定位，不占布局）
2. start() 从第一步播放，可重复调用重播；close() 立即退出并按跳过处理
3. 聚光框按 target 选择器定位并外扩 padding，其余区域由超大 box-shadow 压暗（省掉独立遮罩层）
4. 卡片优先落在目标下方，空间不足自动翻到上方，左右夹在视口内；滚动 / 缩放会实时重算
5. 末步按钮文案变「完成」并触发 onFinish；× 或 Esc 触发 onClose，焦点归还开启前的元素

现场演示：apps/docs/demo.html#sec-tour

### watermark — 水印 Watermark

参数：

- text（必填）：string —— 水印文字，支持 \n 多行
- opacity：number —— 透明度；缺省 0.12
- rotate：number —— 旋转角度（度）；缺省 -20
- gap：number —— 平铺间距像素；缺省 140
- fontSize：number —— 字号像素；缺省 14
- color：string —— 颜色（CSS 串或 var(--ua-*) 令牌）；缺省取 --ua-color-text-muted 的实色
- children：...unknown[] —— 被水印覆盖的内容，作为第二个及之后的实参传入

示例：

```
watermark({ text: '内部资料\n仅限内网', gap: 160 }, card({ title: '受保护内容' }, h('p', null, '正文')))
```

手把手：

1. 第一个参数是配置，其后所有实参都被当作内容渲染在水印层下方
2. 组件用一块 gap 见方的 canvas 画单个旋转单元，再 background-repeat 铺满，因此不需要监听尺寸变化
3. color 传 var(--ua-*) 时会先解析成计算值（canvas 不认 CSS 变量），所以暗色主题下颜色会自动跟随
4. 覆盖层为 pointer-events:none，下方按钮/输入框照常可点；watermark 根节点自带圆角与裁剪

现场演示：apps/docs/demo.html#sec-watermark

### checkboxGroup — 多选组 CheckboxGroup

参数：

- options（必填）：CheckOption[] —— 选项集合：value / label / 可选 disabled
- value：Signal<string[]> —— 选中值数组信号；不传则内部维护
- min：number —— 最少选几项：到下限后已选项禁用，防止取消
- max：number —— 最多选几项：到上限后未选项禁用
- checkAll：boolean —— 是否显示全选行（含半选）；缺省 false
- direction：'horizontal' | 'vertical' —— 排列方向；缺省 horizontal
- disabled：boolean | Signal<boolean> —— 整组禁用
- onChange：(values: string[]) => void —— 变更回调（传副本）

示例：

```
checkboxGroup({ value: permPick, min: 1, max: 3, checkAll: true, options: [{ value: 'read', label: '读取' }, { value: 'write', label: '写入' }] })
```

手把手：

1. options 顺序即渲染顺序；勾选后把整份数组写回 value 信号并触发 onChange
2. max 生效时未选项自动禁用，min 生效时已选项自动禁用——用禁用表达边界，不抛错也不静默回滚
3. checkAll 行的选中/半选只统计未禁用项，禁用项不计入基数
4. direction 传 vertical 改为纵向排列；disabled 支持信号，整组响应式禁用

现场演示：apps/docs/demo.html#sec-checkgroup

### affix — 固钉 Affix

参数：

- offset：number —— 距视口顶部多少像素后转为固定；缺省 0
- position：'top' | 'bottom' —— 固定边：贴顶放头部工具栏，贴底放底部操作条；缺省 top
- target：HTMLElement | Window —— 监听的滚动容器；缺省 window（整页）
- children：...unknown[] —— 被固定的内容，作为第二个及之后的实参传入

示例：

```
affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正文')))
```

手把手：

1. 把要贴住的内容作为实参传入；组件内部包两层：外层占位、内层负责 fixed
2. 滚动到 offset 以内（默认贴视口顶）时内层转 fixed，外层同步撑出等高占位，页面不会塌跳
3. 固定期间宽度与横向位置跟随占位容器，窗口缩放或父容器变宽都自动贴合
4. 内滚容器场景把该元素传给 target，判定改为基于它的滚动位置
5. 底部操作条传 position: 'bottom'：占位容器底边越过「视口底 - offset」时才固定，对应的内联边距写 bottom

现场演示：apps/docs/demo.html#sec-affix

### multiSelect — 多选下拉 MultiSelect

参数：

- options（必填）：CheckOption[] —— 选项集合：value / label / 可选 disabled；每项可加 group 指定分组小标题
- value：Signal<string[]> —— 选中值数组信号；不传则内部维护
- placeholder：string —— 未选时占位文案；已选时让位给标签
- max：number —— 最多选几项：到上限后未选项自动禁用
- maxTagCount：number —— 标签区最多显示几个，超出的折成一枚「+N」（悬停看全量、点击展开弹层）；缺省全部显示
- searchable：boolean —— 是否可输入过滤；缺省 true
- disabled：boolean | Signal<boolean> —— 禁用整个下拉
- onChange：(values: string[]) => void —— 变更回调（传副本）
- clearable：boolean —— 开启后有选中值时在右缘显示一键清空按钮；缺省不渲染

示例：

```
multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })
```

手把手：

1. 选中项以标签形式回显，点标签上的 × 或空输入时按退格即可删除末个标签
2. 点输入区展开弹层并聚焦输入框，输入字符即时过滤（大小写不敏感）
3. 点选项只切换该项、弹层不收起，适合连续多选；到 max 后未选项置灰不可点
4. ↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起（与其它弹层共用一个 Esc 栈）
5. value 传数组信号即受控，外部写入会同步标签区
6. 选中项多时传 maxTagCount: 2 折叠：前 2 枚标签照常逐个删除，其余折成虚线「+N」，悬停 title 给出被折叠的项名，点它直接展开弹层；受控数组一变折叠数就重算
7. 候选分组同样用 group 字段：小标题是不可点击的 li（不占键盘高亮下标），选中后的标签顺序仍按点击顺序而非分组顺序

现场演示：apps/docs/demo.html#sec-multiselect

### anchor — 锚点导航 Anchor

参数：

- items：AnchorItem[] —— 锚点项集合，每项含 href（形如 #sec-x）、title、可选 disabled
- offset：number —— 定位与高亮判定预留的顶部距离，适合有吸顶栏的页面；缺省 0
- container：HTMLElement | Window —— 监听的滚动容器；缺省 window（整页滚动）
- label：string —— nav 的无障碍名称；缺省「锚点导航」
- onChange：(href: string) => void —— 跳转后的回调，参数为命中的 href（适合埋点或联动）

示例：

```
anchor({ items: [{ href: '#sec-button', title: '按钮' }, { href: '#sec-card', title: '卡片' }], offset: 72 })
```

手把手：

1. 在页面任意位置放一个容器，调用 anchor 并把返回的 nav 塞进去（固定侧栏或吸顶栏均可）
2. items 里每条 href 指向页面上真实存在的区块 id，标题即链接文案；目标缺失的项在滚动判定时自动跳过
3. 页面有吸顶导航时把它的高度传给 offset，跳转与高亮判定都会预留这段距离
4. 滚动容器不是整页时用 container 传入该元素；点击走平滑滚动，系统开启「减少动效」时改为瞬移，hash 用 replaceState 回写不堆历史

现场演示：apps/docs/demo.html#sec-anchor

### popconfirm — 二次确认 Popconfirm

参数：

- title（必填）：string —— 气泡标题，通常写成一句问句（如「删除这条记录？」）
- description：string —— 补充说明：影响范围、是否可撤销；缺省给一句通用提示
- confirmText：string —— 确认按钮文案；缺省「确定」
- cancelText：string —— 取消按钮文案；缺省「取消」
- variant：'default' | 'danger' —— 确认按钮语义色；删除类操作传 danger，缺省 default
- placement：'top' | 'bottom' —— 气泡方位；缺省 top，压在触发器上方不盖住被操作的行
- onConfirm（回调）：() => void —— 点确定后的回调：普通回调先收起再执行；返回 Promise 时按钮转「处理中…」并禁用，成功才收起、失败留在气泡里可重试
- onCancel（回调）：() => void —— 取消 / 点外部 / Esc 收起时的回调
- open：Signal<boolean> —— 受控开合信号；不传则内部维护，传了可由外部编程开合

示例：

```
popconfirm({ title: '删除这条记录？', description: '删除后无法恢复', variant: 'danger', confirmText: '删除', onConfirm: () => toast.success('已删除') }, button({ label: '删除', variant: 'danger' }))
```

手把手：

1. 把要保护的触发器元素（按钮 / 图标）作为第二个参数传进去，返回的包裹节点替换原位置
2. 写清 title（问句）与 description（影响范围），删除类操作把 variant 设成 danger，确认按钮会自动变成危险色
3. 点确定先收起弹层再触发 onConfirm，回调里可以放心同步改数据；取消、点外部、Esc 一律走 onCancel
4. 展开时焦点自动落在「取消」上，误按回车不会执行危险操作；需要外部控制开合时传 open 信号
5. 删除 / 归档这类要打后端的操作把 onConfirm 写成 async：气泡不会立刻收起，确认按钮变「处理中…」并禁用，resolve 后才关，reject 时保持展开供重试

现场演示：apps/docs/demo.html#sec-popconfirm

### calendar — 月历 Calendar

参数：

- value：Signal<string> —— 选中日期（YYYY-MM-DD）信号；不传则内部维护
- month：Signal<string> —— 展示月份（YYYY-MM）；不传则内部维护，初始跟随 value 或今天
- marker（回调）：(date: string) => string | null —— 每日标记：返回文案即渲染小徽标（如「3 项」），同时写进 aria-label；返回 null 不标记
- onSelect（回调）：(date: string) => void —— 选中日期回调（点击或 Enter / 空格）
- onMonthChange（回调）：(month: string) => void —— 翻月回调，参数为 YYYY-MM（可在此拉取当月数据）

示例：

```
const day = signal(''); calendar({ value: day, marker: (d) => (d === '2026-10-26' ? '2 项' : null), onSelect: (d) => toast.info(d) })
```

手把手：

1. 把 calendar 放在页面容器里即可，它自带标题行（月份 + 上下月箭头）、星期表头与 6 行网格
2. 选中日期用 value 信号双向绑定；不传则组件内部维护，onSelect 里拿字符串自己处理
3. 需要显示每天有几件事就传 marker：返回非空文案即渲染小徽标，并拼进 aria-label 让读屏也听得到
4. 键盘：Tab 进入网格一次，←→ 逐日、↑↓ 逐周、Enter / 空格 选中；跨月会自动翻页并交接焦点

现场演示：apps/docs/demo.html#sec-calendar

### typography — 排版文本 Typography

参数：

- content（必填）：string | Node —— 文本内容：字符串按纯文本渲染；传 DOM 则原样插入（此时不参与截断与复制）
- variant（默认 text）：'title' | 'text' | 'paragraph' —— 排版形态，决定语义标签与字号：title 出 h1-h5、paragraph 出 p、text 出 span
- level（默认 3）：1 | 2 | 3 | 4 | 5 —— 标题层级，仅 variant=title 生效；越界值会被夹到 1-5
- type：'secondary' | 'success' | 'warning' | 'danger' —— 语义色调；缺省主文本色
- strong（默认 false）：boolean —— 加粗强调
- underline（默认 false）：boolean —— 下划线（可为链接式文案保留指引视觉）
- delete（默认 false）：boolean —— 删除线，用于作废文案；会同时弱化文字颜色
- mark（默认 false）：boolean —— 高亮底纹（警示色 24% 混透明），用于搜索命中片段
- code（默认 false）：boolean —— 等宽代码样式，用于行内字段名 / 命令
- align：'left' | 'center' | 'right' —— 水平对齐；缺省左对齐
- ellipsis：number —— 截断行数（1 起）：超出显示省略号，纯文本自动挂 title 供悬停看全文；缺省不截断
- copyable（默认 false）：boolean —— 右侧附复制按钮：点击写剪贴板并回显「已复制 / 复制失败」，1.2 秒后复原

示例：

```
typography({ variant: 'title', level: 2, content: '部署说明' }); typography({ content: 'RB-2048', code: true, copyable: true }); typography({ variant: 'paragraph', ellipsis: 2, content: '一段很长的说明……' })
```

手把手：

1. 正文一律用 variant='text' 或 'paragraph'，标题用 variant='title' + level，标签自动出 h1-h5，别自己写 h 标签再叠样式
2. 文内强调用装饰位组合：strong 加粗、mark 高亮、code 等宽、underline / delete 表示链接与作废，一个组件内可叠加
3. 长描述（卡片摘要、表格说明）用 ellipsis 给行数：超出显示省略号并自动挂 title，悬停即看全文，不必额外做展开按钮
4. 订单号 / 密钥 / 命令这类要复制的内容传 copyable：按钮独立于文本节点，复制失败会回显「复制失败」而不是静默
5. content 传 DOM 时组件只负责语义标签与类名：截断与复制对 DOM 内容不生效（取不到稳定文本）
6. 段落连续排列时相邻 .ua-type-paragraph 自动留出间距，单个段落不额外撑高，嵌进卡片也不会有空隙

现场演示：apps/docs/demo.html#sec-typography

### grid — 栅格容器 Grid

参数：

- columns（默认 3）：number —— 固定列数（按等宽铺开）；窄屏（≤640px）由样式表统一降为单列
- min：string —— 自适应最小列宽（如 '220px'）：给了就按 auto-fit 铺列，列数随可用宽度自然增减，此时忽略 columns
- gutter（默认 md）：SpaceSize —— 列间距：sm/md/lg 走令牌，数字按像素
- align（默认 stretch）：'start' | 'center' | 'end' | 'stretch' —— 交叉轴对齐：同行子项高度不一时决定各自贴哪边或拉满
- dense（默认 false）：boolean —— 紧凑填充：小项回填前面留下的空洞（瀑布式排布）

示例：

```
grid({ columns: 3, gutter: 'md' }, card({ title: '实例数' }, statistic({ value: total })), card({ title: '错误率' }, statistic({ value: errRate }))); grid({ min: '240px', align: 'start' }, ...items)
```

手把手：

1. 页面骨架用 grid({ columns: n }) 铺等宽卡片：子项宽度由列模板算，别在子项上写 width，窄屏会自动变成单列
2. 卡片宽度要求「不窄于 X」时用 min（如 '240px'）：auto-fit 会按可用宽度决定列数，比手写断点稳，此时不要再传 columns
3. 列间距统一走 gutter（sm/md/lg 令牌或像素），不要在子项上写 margin——相邻 margin 会叠加成双倍间距
4. 同行卡片高度参差时：要等高（默认 stretch），要各自顶端对齐传 align: 'start'
5. 子项必须是单个元素（card / div / 自定义块），grid 只负责排布且已对子项设 min-width: 0，长文本不会撑破列宽
6. 只要横向一排展示且元素不等宽，用 space 更轻；grid 面向二维对齐

现场演示：apps/docs/demo.html#sec-grid

### floatButton — 悬浮操作按钮 FloatButton

参数：

- icon：string | Node —— 图标：字符串按字符（emoji / 符号）渲染，传 DOM 原样插入
- label：string —— 文字标签；给了就出「图标 + 文字」胶囊，缺省为纯图标圆形
- badge：string | number —— 角标（数字或短文案，如 3 / '新'）；缺省不渲染
- tooltip：string —— 悬停提示，同时用作 aria-label；纯图标时必传，否则读屏听到默认「快捷操作」
- position（默认 right-bottom）：'right-bottom' | 'left-bottom' —— 停靠角落；右下与 backTop 同侧，需同时出现时错开一左一右
- offset（默认 24）：number —— 距边距离（px）；同角落堆叠多个时按倍数递增
- onClick（回调）：() => void —— 点击回调

示例：

```
document.body.append(floatButton({ icon: '+', label: '新建工单', position: 'left-bottom', onClick: () => toast.success('已新建') })); document.body.append(floatButton({ icon: '?', tooltip: '帮助中心', badge: 3 }))
```

手把手：

1. 把返回节点挂到 document.body（不是页面容器内）：它是 fixed 定位，挂在容器里会被 overflow: hidden 裁剪
2. 只有图标时务必传 tooltip：它同时是 aria-label，读屏用户靠它知道按钮干什么；有 label 时 tooltip 可省
3. label 存在即「图标 + 文字」胶囊，只有图标即 44px 圆形；两种形态都保证触摸目标不小于 44px
4. 同一角落要放多个按钮时递增 offset（如 24 / 80），或把它们并进同一个 flex 容器统一排布——组件自身不做堆叠计算
5. 右下角已被 backTop 占用，页面同时有返回顶部时用 position: 'left-bottom'
6. 纯展示 + 单回调，没有选中态与开合状态；需要多级操作请用 menu / popover 挂在 onClick 上

现场演示：apps/docs/demo.html#sec-floatbutton

### mentions — @提及输入 Mentions

参数：

- items（必填）：MentionItem[] —— 候选池：每项含 name（插入正文的名字）、label（显示名）、description（说明）、keywords（只参与过滤的额外匹配词）
- value：Signal<string> —— 受控文本信号；不传则组件内部维护（拿不到外部读取时不要传）
- rows（默认 3）：number —— 输入框行数
- placeholder：string —— 占位文案
- onSelect（回调）：(item: MentionItem) => void —— 选中某人后的回调（弹层已收起，正文已替换）
- onInput（回调）：(text: string) => void —— 每次输入的回调，参数为当前全文（已写回 value 信号）

示例：

```
const text = signal(''); mentions({ items: [{ name: 'zhangsan', label: '张三', description: '前端', keywords: ['zs'] }], value: text, placeholder: '输入 @ 叫人', onSelect: (it) => toast.info(it.label) })
```

手把手：

1. 在输入框里键入 @ 触发弹层，继续打字按 name / keywords 过滤（拼音、工号放 keywords，不出现在候选行里）
2. 键盘：↑↓ 环形移动高亮、Enter 或 Tab 选中、Esc 只收这一层弹层（stopPropagation，不会连带关掉外层弹窗）
3. 选中后组件按光标位置把 @片段替换成「@名字 + 空格」，光标落在其后，可以接着打字
4. 受控用法：传 value 信号并在 onSelect / onInput 里读最新全文；外部改信号会同步回输入框，别同时再手写 input 事件
5. 弹层贴在输入框下沿而非跟随光标行（多行输入里更稳定，也不会被行高算错），需要跟随光标请自绘
6. 要限制可选人范围就在 items 上做过滤，组件不做远程搜索；远程场景用 onInput 拉数据后重建 items

现场演示：apps/docs/demo.html#sec-mentions

### treeSelect — 树形选择 TreeSelect

参数：

- nodes（必填）：TreeNode[] —— 根节点列表，结构同 tree：key / label / children / disabled
- value：Signal<string> —— 单选选中 key；不传则内部维护
- checked：Signal<string[]> —— 多选勾选 key 集合（multi 时生效）；不传则内部维护
- multi（默认 false）：boolean —— 多选模式：显示勾选框、父子级联、选中后弹层不收起
- searchable（默认 false）：boolean —— 弹层内带搜索框（按标签过滤并保留父链）
- placeholder（默认 请选择）：string —— 未选中时触发器文案
- disabled（默认 false）：boolean —— 禁用整个触发器（不响应点击）
- onSelect（回调）：(node: TreeNode) => void —— 单选选中回调（此时弹层已收起）
- onChange（回调）：(keys: string[]) => void —— 多选勾选变更回调，传副本且父子已级联

示例：

```
const dept = signal(''); treeSelect({ nodes: [{ key: 'tech', label: '技术中心', children: [{ key: 'fe', label: '前端组' }] }], value: dept, searchable: true, onSelect: (n) => toast.info(n.label) })
```

手把手：

1. 表单里选层级归属（部门 / 分类 / 区域）用它，不要用 cascader——cascader 要求逐级选到叶子，treeSelect 允许选中间节点，也允许任意深度
2. 单选默认选中即收起；要连续看结构就传 multi，勾选后弹层保持展开，触发器回显「已选 N 项」
3. 需要在大树里找人/找分类就开 searchable：搜索按标签过滤并保留命中节点的父链，路径不会断
4. Esc 与其他弹层共享键盘栈（只关最上层），点外部也会收起；展开时焦点自动落到搜索框
5. 选中值只存 key（单选 value / 多选 checked 数组），显示名由 nodes 反查——改 label 不需要迁移数据
6. 禁用单个节点用 nodes 里的 disabled（该节点不可选，子节点仍可选）；禁用整个控件用 disabled

现场演示：apps/docs/demo.html#sec-treeselect

### link — 链接 Link

参数：

- href（必填）：string —— 目标地址：站内相对路径或绝对 URL
- label（必填）：string —— 链接文案
- target：'_self' | '_blank' | '_parent' | '_top' —— 打开方式；缺省同窗口。传 _blank 会自动按站外处理（补 rel 与图标）
- external（默认 false）：boolean —— 站外链接：补 ↗ 图标、强制新窗口、加 rel=noopener noreferrer，并给读屏播报「在新窗口打开」
- muted（默认 false）：boolean —— 弱化色，用于次要链接与页脚
- disabled（默认 false）：boolean —— 禁用：去掉 href、拦点击，保留可聚焦与 aria-disabled（仍能被 Tab 到，读屏能听出不可用）
- onClick（回调）：(e: Event) => void —— 点击回调（禁用时不触发；可用于埋点，returnValue 不影响默认跳转）

示例：

```
link({ href: '/docs', label: '查看文档' }); link({ href: 'https://example.com', label: '外部站点', external: true }); link({ href: '#', label: '说明页', muted: true })
```

手把手：

1. 站内跳转用相对路径、站外用 external: true：会自动补 ↗、target=_blank、rel=noopener noreferrer，并给读屏播报开了新窗口
2. 页脚 / 次要说明用 muted 弱化；正文里的行内链接保持默认主色，与 typography 的 underline 区分用途（后者不跳转）
3. 需要「点了先确认再跳」这类拦截，在 onClick 里做判断后自行处理，组件不接管默认行为（除禁用外都放行）
4. 禁用态只是去掉 href 并保持 aria-disabled，不做视觉上的「像普通文字」处理——需要完全不可聚焦请在外层控制渲染
5. 纯样式的「看起来像链接」用 typography({ underline: true })，不要为了样式引入带 href 的 a 标签

现场演示：apps/docs/demo.html#sec-link

### avatarGroup — 头像组 AvatarGroup

参数：

- items（必填）：AvatarItem[] —— 成员列表：每项含 name（姓名）、src（图片地址）、variant（首字底色语义档）
- max（默认 4）：number —— 最多展示几个，超出折叠成 +N（至少展示 1 个）
- size（默认 32）：number —— 头像直径像素，与 avatar 的 size 同义

示例：

```
avatarGroup({ items: [{ name: '张三' }, { name: '李四', variant: 'success' }, { name: '王五' }], max: 4 })
```

手把手：

1. 协作列表 / 评论头部展示参与人用它，比并排 avatar 省横向空间：叠放用负边距 + 底色描边切出入口
2. 成员多时设 max：前 max 个照常显示，其余折叠成一个 +N，悬停任意头像都能看到全部姓名
3. 只传 name 即首字占位，有图片就传 src；底色语义档用 variant（default / success / warning / danger）
4. 整组是展示型元素：要有点击行为就在外面包 button 或 link，不要给单个头像加交互
5. size 与单个 avatar 一致，混排时把并排 avatar 与 avatarGroup 传同一个 size，视觉才对得上

现场演示：apps/docs/demo.html#sec-avatar

### virtualList — 虚拟列表 VirtualList

参数：

- items（必填）：unknown[] —— 全量数据（不切片，由组件按滚动位置取窗口）
- itemHeight（必填）：number —— 每行固定高度像素；虚拟化前提是行高一致
- height（必填）：number —— 容器可视高度像素，超出即容器内滚动
- overscan（默认 5）：number —— 上下各多渲染的缓冲条数；缺省 5，滚动更平滑
- renderItem（必填）：(item, index) => unknown —— 行内容渲染器：返回字符串 / DOM / 响应式值

示例：

```
const vlist = virtualList({ items: users, itemHeight: 44, height: 320, renderItem: (u) => u.name }); vlist.scrollTo(500);
```

手把手：

1. itemHeight 必须与每行真实高度一致：行高由组件钉死，行内容超高会溢出，请保持单行或内部滚动
2. height 是容器可视高度（像素），列表在容器内滚动，不会撑开页面
3. renderItem 第二参是原始下标（全量数据中的位置），不是窗口内序号
4. 滚回某条用句柄 scrollTo(index)，越界自动钳制；DOM 里只存在可视窗口的行，勿按全量下标查行节点
5. 数据整体变化（换数据源）请销毁重建实例：items 是创建时快照，不响应外部数组突变
6. 滚动事件内部已 rAF 合帧，滚动回调无需节流

现场演示：apps/docs/demo.html#sec-vlist

### commandPalette — 命令面板 CommandPalette

参数：

- open（必填）：Signal<boolean> —— 显隐信号：外部写 true 唤起，组件内部关闭也会写回
- items（必填）：CommandPaletteItem[] —— 全量命令，创建时快照；含 id / label / group / hint / keywords
- placeholder：string —— 搜索框占位文案；缺省「搜索命令…」
- emptyText：string —— 无匹配时的空状态文案；缺省「没有匹配的命令」
- hotkey（默认 true）：boolean —— 是否注册 ⌘K / Ctrl+K 全局唤起；缺省 true，多实例场景可关掉自己绑
- onSelect（必填）：(item) => void —— 回车或点击选中后回调：先收起并归还焦点，再回调

示例：

```
const cmdk = commandPalette({ open: signal(false), items: [{ id: 'save', label: '保存当前页', group: '文件', hint: '⌘S' }, { id: 'invite', label: '邀请成员', group: '成员' }], onSelect: (it) => toast.info(`执行 ${it.label}`) }); document.body.append(cmdk.el); cmdk.open();
```

手把手：

1. 把返回的 el 挂到 body（面板是 fixed + 遮罩，挂在 section 里也定位正确），之后用 open 信号或句柄 open()/close() 控制显隐
2. items 是创建时快照：命令表变化（权限变化后菜单增减）请重建实例，不要直接改数组
3. 过滤看 label / hint / keywords 三层：中文别名与拼音首字母塞进 keywords 即可支持，不必改组件
4. 同组连续排列才合并成一段并画组标题；分组顺序按 items 顺序，组件不排序
5. 键盘：↑↓ 环形选择、Home/End 跳首尾、回车执行、Esc 交给弹层栈（嵌套弹窗时只关最上层）；鼠标划过即高亮
6. hotkey 缺省注册 ⌘K / Ctrl+K，同一页面多个面板请只留一个开 hotkey，其余传 hotkey: false

现场演示：apps/docs/demo.html#sec-cmdk

### timePicker — 时间选择 TimePicker

参数：

- value：Signal<string> —— 选中时间信号（HH:mm）；不传则内部维护
- minuteStep：number —— 分钟步长；缺省 5，超过 30 按 30 处理（如 15 只有 00/15/30/45）
- clearable：boolean —— 显示清空按钮 ×（仅在有值时出现）；缺省 false，键盘 Delete / Backspace 等价
- size：'sm' | 'md' | 'lg' —— 触发器高度档位，与 textField / select 同口径；缺省 md
- placeholder：string —— 未选时占位文案；缺省「请选择时间」
- disabled：boolean | Signal<boolean> —— 禁用触发器
- onChange：(time: string) => void —— 写回回调：选时即触发一次（分默认 00），选完分再触发一次

示例：

```
const at = signal('09:30'); timePicker({ value: at, minuteStep: 15, onChange: (t) => toast.info(`定在 ${t}`) })
```

手把手：

1. 创建信号 at = signal('09:30') 并传入 value：组件读它显示、选完写回，双向绑定
2. 点触发器展开时 / 分两列：当前值所在格高亮并自动滚到列中部；还没选过时停在此刻（当前小时 / 当前分钟档），打开即可就近选
3. 先点时列定小时（分默认落到首个步长值，值立刻可用），再点分列即完成并收起，焦点回到触发器
4. 列内可用 ↑↓ 环形移动、Home/End 直达首末；Tab、Esc、点击外部都能正常进出与收起
5. 需要 15 分钟一档就传 minuteStep: 15，步长只影响分列的可选值，时列始终 24 档
6. 需要允许回到「未选」态就传 clearable: true：有值时触发器右侧出现 ×（不进 Tab 序），聚焦触发器按 Delete / Backspace 也能清空，回调收到空串
7. 要和输入框并排对齐就传 size（sm / md / lg）：高度走 --ua-control-h-* 三档，与 textField、select 的档位完全同源，不会出现「旁边的输入框矮 6px」
8. 传入值不在步长档上时（如 09:07 + 15 分钟档）不会「没有高亮」：分列按最近档高亮并滚到位，点它即落到 09:15；原值在触发器上照常显示，直到用户重新选
9. 收起态键盘微调：聚焦触发器按 ↑↓ 按步长加减（12:30 步长 15 → 12:45），Shift + ↑↓ 按小时加减，跨 00:00 / 23:xx 自动环形；改完同样触发 onChange
10. 打开弹层后焦点直接落在时列当前格（键盘用户不必再 Tab 找）：列内 ↑↓ 立即生效，Esc 收起后焦点回到触发器

现场演示：apps/docs/demo.html#sec-timepicker

### pinInput — 验证码输入 PinInput

参数：

- value（必填）：Signal<string> —— 当前验证码（数字拼接串）信号，双向绑定：格子输入写回、外部赋值同步回格子
- length（默认 6）：number —— 格数；缺省 6
- mask（默认 false）：boolean —— 密文显示（type=password，并声明 one-time-code 便于短信验证码自动填充）；缺省明文
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onChange（回调）：(value: string) => void —— 任一格变化回调（参数为当前已输入拼接串）

示例：

```
const code = signal(''); pinInput({ value: code, length: 6, onChange: (v) => console.log(v) })
```

手把手：

1. 用 signal('') 创建验证码信号并传入 value；拼接串随输入实时更新，长度满格即验证码就绪
2. 输入一位自动进下一格；退格在本格为空时回删上一格；←/→ 在格间移动；聚焦即全选，直接敲键覆盖
3. 粘贴会自动分发：整段验证码（位数 ≥ 格数）从首格铺起，零散片段从当前格续填
4. mask: true 走密文显示并声明 one-time-code，短信验证码可自动填充；disabled 支持布尔或信号禁用

现场演示：apps/docs/demo.html#sec-pininput

### copyButton — 复制按钮 CopyButton

参数：

- text（必填）：string | (() => string) —— 待复制内容；传函数则在点击瞬间取实时值（如输入框当前值）
- label（默认 复制）：string —— 按钮文案
- copiedLabel（默认 已复制）：string —— 复制成功后的短暂文案（2s 后还原为 label）
- variant（默认 ghost）：'primary' | 'ghost' | 'danger' —— 视觉强度，沿用按钮三档
- size（默认 md）：'sm' | 'md' | 'lg' —— 高度档位
- disabled：boolean | Signal<boolean> —— 禁用态：布尔直接置位，传信号则动态同步
- onCopied（回调）：(text: string) => void —— 复制成功回调（参数为实际写入的内容）

示例：

```
copyButton({ text: 'npm install @ui-arch/ui', onCopied: (t) => console.log(t) })
```

手把手：

1. 传 text 即可用，缺省幽灵按钮、文案「复制」；复制成功后短暂换成「已复制」，2s 自动还原
2. 内容需要实时取就传函数：text: () => textFieldValue()，点击瞬间才求值
3. label / copiedLabel 可改文案；variant / size 透传给内部 button，视觉与普通按钮完全一致
4. 非安全上下文（http）或旧浏览器自动降级为隐藏 textarea + execCommand，无需业务处理兼容

现场演示：apps/docs/demo.html#sec-copybutton

### countdown — 倒计时 Countdown

参数：

- seconds（必填）：number —— 总时长（秒），> 0
- running（默认 true）：boolean —— 创建即开始倒数；传 false 用句柄 start 手动启动
- format：(seconds: number) => string —— 展示格式化；缺省 mm:ss 两位补零
- onFinish（回调）：() => void —— 归零回调（自动停表后触发）

示例：

```
const cd = countdown({ seconds: 60, onFinish: () => console.log('结束') }); // cd.start / stop / reset
```

手把手：

1. 传 seconds 即可用，缺省挂载即开始、mm:ss 展示；返回句柄 { el, start, stop, reset }
2. running: false 先摆好再手动 start，典型场景是「发送验证码」后才开始 60s 重发倒计时
3. 归零自动停表并触发 onFinish；之后 start() 会重开一轮，reset() 复位到总时长
4. format 自定义展示：如只显示秒 (s) => `${s}s`，或带上小时位

现场演示：apps/docs/demo.html#sec-countdown

### kbd — 键盘按键 Kbd

参数：

- keys（必填）：string | string[] —— 按键内容：单键字符串或多键组合数组（组合以 + 连接展示）

示例：

```
kbd({ keys: 'Esc' })、kbd({ keys: ['Ctrl', 'K'] })
```

手把手：

1. 传字符串展示单键帽：kbd({ keys: "Esc" })，适合文档里说明单个按键
2. 传数组展示组合键：kbd({ keys: ["Ctrl", "K"] })，键帽之间以弱化的 + 连接（+ 对读屏隐藏）
3. 常与提示文案混排：行内 baseline 对齐，键帽底边加厚一线出立体感
4. 组件只画键帽，不做平台映射：Ctrl 与 ⌘ 的替换由调用方按平台判断后传进来（写死 Ctrl 在 Mac 上就是错的）

现场演示：apps/docs/demo.html#sec-kbd

### highlight — 关键词高亮 Highlight

参数：

- text（必填）：string | Signal<string> —— 原文；传信号则随信号变化响应式重画
- keyword（必填）：string | Signal<string> —— 关键词（大小写不敏感、全部命中，首尾空白忽略）；空串不高亮、原样展示

示例：

```
highlight({ text: 'Alpha Beta', keyword: 'beta' })
```

手把手：

1. 传 text + keyword 即可，命中片段以淡黄 mark 底展示，适合搜索结果 / 补全列表的匹配说明
2. 两个入参都接受信号：搜索框输入实时联动高亮，无需手动重渲染
3. 大小写不敏感且全部命中（非只第一处）；关键词两侧空白自动忽略
4. keyword 为空或纯空白时原样返回文本，既不抛错也不全高亮：搜索框清空那一刻不用特判

现场演示：apps/docs/demo.html#sec-highlight

### inputTag — 标签输入 InputTag

参数：

- value（必填）：Signal<string[]> —— 已选标签集合，双向绑定：成标签与删 chip 都写回信号
- placeholder：string —— 未满时的占位文案；缺省「输入后回车添加」
- max：number —— 最多允许的标签条数；缺省 0 表示不限，满额后输入框禁用并提示上限
- maxLength：number —— 单个标签最大字符数；缺省 24，超出直接截断
- allowDuplicate：boolean —— 是否允许重复标签；缺省 false（重复的静默丢弃）
- disabled：boolean | Signal<boolean> —— 禁用态：布尔或信号，true 时不可输入也不可删
- ariaLabel：string —— 输入框无障碍标签；缺省「标签输入」
- onChange：(tags: string[]) => void —— 标签集合变化回调，参数是变化后的完整集合

示例：

```
const tags = signal(['前端', '设计']);
inputTag({ value: tags, max: 5, onChange: (list) => console.log(list.length) })
```

手把手：

1. 传一个 signal<string[]> 作为 value：回车或逗号把当前输入转成 chip，写回信号后立即重画
2. 点 chip 上的 × 删除单个标签，删完焦点交回输入框（chip 一删按钮就消失，不接住焦点键盘用户要从头 Tab）；输入框为空时按退格键删掉最后一个（手不离键盘也能改）
3. 输入后直接失焦也会成标签，不必强行回车；max 满额后输入框禁用，占位文案变成「最多 N 个标签」
4. 整段粘贴「上海,北京，深圳」这类带分隔符的文字会自动拆成多个标签（半角/全角逗号、换行、制表符都认），单个值仍走浏览器默认粘贴
5. 要区分大小写重复判断就传 allowDuplicate: true；默认按原样去重
6. 样式复用输入组与标签：外框聚焦光环、chip 圆角与语义色都跟既有组件一致，无新增视觉字面量

现场演示：apps/docs/demo.html#sec-inputtag

### layout — 布局骨架 Layout

参数：

- header：unknown —— 页头内容（文案 / 节点）；缺省不渲染页头
- sider：unknown —— 侧栏内容（通常是 menu）；缺省则内容区独占整行
- footer：unknown —— 页脚内容；缺省不渲染页脚
- siderWidth：number —— 侧栏展开宽度（px）；缺省 220
- collapsible：boolean —— 是否可折叠：true 时页头左侧出现折叠开关
- collapsed：Signal<boolean> —— 折叠态信号；传入则双向绑定（外部可读数），缺省内部自管
- sticky：boolean —— 页头是否吸顶；缺省 false（长内容里建议打开）

示例：

```
layout({ header: '控制台', sider: menu({ items }), footer: '© 2026', collapsible: true, sticky: true }, card({ title: '内容区' }, ...))
```

手把手：

1. 把整页交给 layout：children 就是内容区，页头 / 侧栏 / 页脚用 header / sider / footer 三个入参一次给全
2. 侧栏通常放 menu 或 anchor，宽度用 siderWidth 调（缺省 220px，够放四项中文菜单）
3. 需要折叠时传 collapsible: true，页头左侧自动出现开关；想把折叠态接到别处（比如只显示图标栏）就传 collapsed 信号读它的值
4. 长内容页面打开 sticky，页头吸顶；内容区自己滚动，不会把整页撑高
5. 只要内容不要骨架时省略 header / sider / footer：不套多余容器，内容区独占整行

现场演示：apps/docs/demo.html#sec-layout

### jsonViewer — JSON 树查看 JsonViewer

参数：

- value：unknown —— 任意可序列化数据：对象 / 数组 / 标量都行
- depth：number —— 默认展开层数；缺省 2（看得到一级结构又不至于刷屏）
- rootLabel：string —— 根节点键名；缺省 root
- maxHeight：number —— 最高高度（px），超出在框内滚动；缺省不限高
- ariaLabel：string —— 可访问名；缺省「JSON 数据」

示例：

```
jsonViewer({ value: payload, depth: 2, rootLabel: 'response', maxHeight: 320 })
```

手把手：

1. 把接口返回的原始对象直接交给 value：不用自己写 JSON.stringify 再塞进 pre，组件会按层折叠
2. 默认展开两层；数据层级深时调小 depth（1 层只看顶层键），扁平数据可调大
3. 长数据给 maxHeight，超出在框内滚动，滚动条已是统一细条（暗色下不会露出原生亮条）
4. 数组下标与对象键都走同一套行渲染，收起时右侧显示「3 项 / 2 个键」，先看规模再决定要不要展开
5. 要展示编辑结果时不要用它（只读）：输入侧请用 textarea，展示侧再交给 jsonViewer

现场演示：apps/docs/demo.html#sec-jsonviewer

### diffView — 文本差异对比 DiffView

参数：

- before：string —— 旧文本（按行比对，CRLF 与 LF 等价）
- after：string —— 新文本
- showStats：boolean —— 是否在顶部显示 +N / −N 统计条；缺省 true
- ariaLabel：string —— 可访问名；缺省自动播报「新增 N 行，删除 M 行」
- ignoreWhitespace：boolean —— 比较时忽略行内空白差异（格式化提交不再淹没真实改动）；缺省 false

示例：

```
diffView({ before: oldText, after: newText })
```

手把手：

1. 把改前 / 改后两份文本整串交给 before 与 after：不用先切行，CRLF 与 LF 视为同一行
2. 行级对齐先剪公共前后缀、再对中间段做 LCS：改两行不会把整篇标成差异
3. 增删行同时有底色与行首 + / − 符号两条通道，色盲用户也能分辨；行号列不参与复制
4. 顶部统计条给 +N / −N，读屏在容器上直接播报同一组数字，不必逐行听
5. 超大文本（中间段行数乘积超过 40 万）会退化成「整段替换」——语义仍对，只是不再逐行对齐；要逐行对齐就先分段比对
6. 格式化提交制造的大量「只差缩进 / 行尾空格」噪音，打开 ignoreWhitespace 后再看：显示仍是原文缩进，只是比对时把行内空白归一

现场演示：apps/docs/demo.html#sec-diff

### codeBlock — 代码块 CodeBlock

参数：

- code：string —— 代码原文：显示与复制都用它（不读 DOM，避免行号混进剪贴板）
- lang：string —— 语言名（如 ts / css）；无 title 时作头部标题
- title：string —— 头部标题（如文件名）；优先于 lang
- lineNumbers：boolean —— 是否显示行号列；缺省 true（单行代码自动省略）
- wrap：boolean —— 长行是否折行（窄屏 / 长日志场景）；缺省 false（横向滚动，行号保持对齐）
- maxHeight：number —— 代码区最高高度（px），超出在框内滚动；缺省不限高
- copyable：boolean —— 右上角是否出现复制按钮；缺省 true
- onCopy：(text: string) => void —— 复制成功回调（参数为实际写入剪贴板的内容）
- ariaLabel：string —— 可访问名；缺省「代码块」

示例：

```
codeBlock({ code: source, title: 'main.ts', lang: 'ts', maxHeight: 320 })
```

手把手：

1. 把代码原文交给 code：显示与复制都读这一份，不要从 DOM 里取文本（行号会污染剪贴板）
2. 头部标题用 title（文件名）或 lang（语言名），两者都给时 title 优先
3. 长代码给 maxHeight，超出在框内滚动；滚动条已是统一细条，暗色下不会露原生亮条
4. 单行代码自动省掉行号列；想彻底不要行号传 lineNumbers: false
5. 不需要复制按钮（例如代码本身是操作步骤）传 copyable: false；连标题都没有时头部整条省掉
6. 窄屏或长日志要折行就传 wrap: true：长行按容器宽度换行，行号仍只占首行左侧；对齐要求高的代码别开，折行会让缩进失去参照

现场演示：apps/docs/demo.html#sec-code

### signaturePad — 手写签名 SignaturePad

参数：

- value：Signal<string> —— 签字结果 dataURL 信号，双向绑定；清空写空串
- height：number —— 画布高度（px）；缺省 160
- penWidth：number —— 笔宽（px）；缺省 2
- hint：string —— 底部提示文案；缺省「在上方区域手写签名」
- disabled：boolean | Signal<boolean> —— 禁用态：布尔或信号，禁用时不响应手写
- ariaLabel：string —— 画布可访问名；缺省「手写签名区」
- onChange：(dataUrl: string) => void —— 收笔 / 清空时回调（清空回调空串）

示例：

```
const pad = signaturePad({ value: sign, height: 140 }); mount('#box', () => pad.el); pad.toDataURL(); pad.clear();
```

手把手：

1. 把画布挂上去即可手写：宽度跟随容器、高度由 height 给（缺省 160），不用自己算 DP R
2. 签字结果在收笔瞬间写回 value 信号（PNG dataURL）；要提交表单就把它塞进字段值，清空会写回空串
3. 底部「清空」按钮在未落笔或禁用时自动不可点，避免空操作；也能用返回的 handle.clear() 外部触发
4. 需要判空用 handle.isEmpty()，取图用 handle.toDataURL()；两者与信号同源，不会出现「有图但信号是空」
5. 禁用传布尔或信号：布尔直接置位，信号随状态变灰并挡住手写；移动端画布已关掉浏览器手势，不会边签边滚页

现场演示：apps/docs/demo.html#sec-sign

### loading — 区域加载遮罩 Loading

参数：

- active：boolean | Signal<boolean> —— 是否加载中：布尔直接置位，传信号则随状态显隐
- text：string —— 遮罩文案；缺省只显示转圈（读屏仍报「内容加载中」）
- delay：number —— 出场延迟（ms）；缺省 120，快请求不闪遮罩
- spinnerSize：number —— 转圈直径（px）；缺省 20
- minHeight：number —— 最小高度（px）；内容为空时兜住遮罩高度

示例：

```
loading({ active: busy, text: '正在刷新订单' }, dataTable({ ... }))
```

手把手：

1. 把要遮挡的那块内容作为 children 交进来，active 传布尔或信号：刷新时盖上遮罩，数据回来后自动收起
2. 内容不隐藏、只降透明度——布局与尺寸原地保留，遮罩收起时不会跳一下
3. 默认延迟 120ms 出场：接口快于这个时间就不闪遮罩；想立刻显示传 delay: 0
4. 遮罩文案写明在等什么（「正在刷新订单」），读屏同一句也会播报；只要转圈就省略 text
5. 内容可能为空时给 minHeight，否则高度塌成 0 遮罩无处可盖

现场演示：apps/docs/demo.html#sec-loading

### relativeTime — 相对时间 RelativeTime

参数：

- value：string | number | Date —— 目标时间：ISO 串 / 时间戳（10 位按秒、13 位按毫秒）/ Date
- refresh：number —— 自动刷新间隔（ms）；缺省 30000，传 0 不刷新
- threshold：number —— 超过该秒数改用绝对日期；缺省 7 天
- fallback：string —— 时间非法时的占位文案；缺省「—」
- ariaLabel：string —— 可访问名（如「下单时间」），让读屏知道这个时间是什么

示例：

```
relativeTime({ value: row.createdAt, ariaLabel: '下单时间' })
```

手把手：

1. 把后端给的时间原样交进来：ISO 串、10 位秒级戳、13 位毫秒戳、Date 都认，不用自己 new Date
2. 默认每 30 秒刷新一次文案（「3 分钟前」会在不刷新页面的情况下变「4 分钟前」）；列表里条数极多时可把 refresh 调大或传 0
3. 超过 7 天自动改显示绝对日期（YYYY-MM-DD HH:mm），想更早切换就调小 threshold
4. 精确时间放在 title：鼠标悬停可看，不必为「想知道具体哪天」再点开详情
5. 时间非法（空串 / 格式错）不猜也不抛，显示 fallback（缺省「—」），列表不会因一条脏数据空白

现场演示：apps/docs/demo.html#sec-reltime

### sparkline — 迷你趋势线 Sparkline

参数：

- values：number[] —— 数值序列（按时间顺序）；非有限数会被剔除
- width：number —— 宽度（px）；缺省 120
- height：number —— 高度（px）；缺省 32
- area：boolean —— 是否填充折线下方面积；缺省 true
- marker：boolean —— 是否标记末点；缺省 true（一眼看出当前值位置）
- ariaLabel：string —— 可访问名；缺省「趋势图」

示例：

```
sparkline({ values: last7Days, width: 140, height: 36 })
```

手把手：

1. 把按时间顺序的数值数组交给 values：不用自己算最大最小与像素坐标，组件按 viewBox 等比缩放
2. 和 statistic 并排用：主数值走 statistic、走向走 sparkline，趋势线不画坐标轴也不带交互
3. 不要面积就传 area: false，不要末点圆点传 marker: false（极窄的表格单元格里圆点会糊成一团）
4. 改颜色在父级设 color：线、面积、圆点都读 currentColor，放进 card 或表格行自动跟随文字色
5. 少于两个点时画不出趋势，组件返回空图——请在外面判空并给「暂无数据」，别让它空着占位

现场演示：apps/docs/demo.html#sec-spark

### heatmap — 日历热力图 Heatmap

参数：

- values：{ date: string; count: number }[] —— 每一项 = 某天与当天数值；date 用 YYYY-MM-DD，同日多条自动求和
- weeks：number —— 显示最近多少周；缺省 12
- cellSize：number —— 格子边长（px）；缺省 11
- legendText：string —— 图例左端文案；缺省「少」（右端固定「多」）
- ariaLabel：string —— 可访问名；缺省「最近 N 周活跃度」
- formatValue：(count: number, date: string) => string —— 悬停提示文案；缺省「日期 · 数量」

示例：

```
heatmap({ values: activity, weeks: 12, formatValue: (n, d) => d + ' 提交 ' + n + ' 次' })
```

手把手：

1. values 只给「有数据的那几天」即可：缺失的日期自动按 0 渲染，不必把 84 天全部补齐
2. 末列永远对齐到本周（周一到周日完整七行），所以数据是滚动的，不会出现半截列
3. 同一天多条记录会求和（合并成一个格子），上游按天预聚合也行，两种都吃
4. 格子颜色是相对深浅：按序列最大值归一成四档——所以它说的是「哪天更密」，不是绝对值高低
5. 默认 12 周、11px 格子；嵌进窄卡片就调小 weeks，看不出格子边界就调大 cellSize

现场演示：apps/docs/demo.html#sec-heat

### progressRing — 环形进度 ProgressRing

参数：

- value：number | Signal<number> —— 当前进度 0-100；超出范围自动夹取，信号变化时自动重绘
- size：number —— 外径（px）；缺省 96
- thickness：number —— 环宽（px）；缺省 8
- showText：boolean —— 是否显示中心文字；缺省 true（关掉时请自行保证 aria 可读）
- label：string —— 中心文字；缺省「N%」

示例：

```
progressRing({ value: pct, size: 96 })
```

手把手：

1. value 给数字就是静态环，给信号就是活环：不用自己算弧长，组件按 0-100 换算 dasharray
2. 0 点固定在 12 点方向顺时针走，和进度条的心理模型一致，不需要额外传旋转参数
3. 要放进表格或卡片角落就用 size: 56 + showText: false 的小环，文字由附近的主体数字承担
4. 换色在父级设 color：进度弧读 currentColor，底环固定描边色（--ua-color-border），不引入第二个色源
5. 这是个「环形进度条」，不是仪表盘：只表达 0-100 单一进度，多段占比请用饼图类组件

现场演示：apps/docs/demo.html#sec-ring

### noticeBar — 通知条 NoticeBar

参数：

- text：string —— 通知正文；单行不换行，超宽需配合 scroll
- variant：'info' | 'success' | 'warning' | 'danger' —— 语义色；缺省 info
- icon：string —— 左侧符号；缺省按 variant 取（ℹ / ✓ / ! / ×）
- scroll：boolean —— 正文超宽时跑马灯滚动；缺省 false（静态，超出裁切）
- closable：boolean —— 是否显示关闭按钮；缺省 true
- onClose：() => void —— 关闭回调

示例：

```
noticeBar({ text: '系统将于今晚 23:00 维护 10 分钟', scroll: true })
```

手把手：

1. 和 alert 的分工：alert 可多行带标题、占块；通知条只占一行，适合页顶常驻播报（维护公告、试用期提醒）
2. 文案比容器宽就传 scroll: true：正文从右向左匀速走，悬停自动暂停，读得完不用追
3. 只想播报不需要关闭就传 closable: false：右侧不再留 × 位，横条更紧凑
4. 颜色只是第二线索：左侧符号按 variant 自动配（ℹ / ✓ / ! / ×），色弱或灰度打印也能分辨
5. 跑马灯在减少动效偏好下自动停成静态首屏——完整原文始终在 title 里，不必为可访问性另写一份
6. 点 × 是先淡出再隐藏（200ms，和 alert 同一档），回调 onClose 立刻触发：想同步清理外部状态就在回调里做，别等隐藏

现场演示：apps/docs/demo.html#sec-notice

### sortableList — 拖拽排序列表 SortableList

参数：

- items（必填）：string[] —— 全部条目文案；文案即排序键，要求唯一（重名会让顺序回读撞车）
- value：Signal<string[]> —— 受控顺序信号；传了则内部改动写回，外部改动即重排（拖拽中跳过）
- onChange（回调）：(next: string[]) => void —— 顺序变化回调，拖放落定 / 键盘搬位 / move 调用后各触发一次
- disabled（默认 false）：boolean | Signal<boolean> —— 禁用拖拽与键盘搬位；缺省 false
- emptyText：string —— 无条目占位文案；缺省「暂无可排序项」

示例：

```
const sort = sortableList({ items: ['需求评审', '接口联调', '灰度发布'], onChange: (next) => console.log(next) });
container.append(sort.el);
```

手把手：

1. items 传全部条目文案、顺序即当前顺序：文案同时是排序键，所以必须唯一（重名会分不清谁被搬动）
2. 鼠标 / 触摸按住任意一行拖动，指针越过邻行中线就换位；松手后写回 value 信号并触发 onChange，拖拽期间不重排避免手势中断
3. 键盘等价：Tab 聚焦某行后按 Alt+↑ / Alt+↓ 上下搬一格、Alt+Home / Alt+End 搬到首尾（裸 ↑↓ 留给页面滚动）
4. 被拖的行以虚线边 + 抬升投影标示（不用整行降透明度：那会把正文对比压到 4.86，低于正文 7:1 口径），落点由指针位置决定，不会出现「拖了三格只动一格」的错觉
5. 外部要按代码调整顺序用句柄 move(from, to)，下标越界或禁用时静默忽略；order() 以 DOM 为唯一真相回读当前顺序
6. 什么时候别用：条目超过一屏（拖到边缘不会自动滚动，改用筛选或上下按钮）、需要跨列表搬运（用两个实例并自己管状态）

现场演示：apps/docs/demo.html#sec-sortable

### masonry — 瀑布流 Masonry

参数：

- items（必填）：unknown[] —— 每个卡片内容（字符串 / DOM / 响应式值）；数组顺序即铺放顺序，列高不等由内容自己决定
- columns：number —— 固定列数；传了即不随容器宽度变化，与 minColumnWidth 互斥
- minColumnWidth（默认 240）：number —— 自适应列宽下限（像素）；缺省 240，容器越宽列数越多
- gap（默认 var(--ua-space-3)）：string —— 列间距与行间距（CSS 长度或令牌值）；缺省 var(--ua-space-3)

示例：

```
masonry({ minColumnWidth: 220, items: shots.map((s) => card({ title: s.title }, image({ src: s.url }))) })
```

手把手：

1. items 里直接放 card / image / 任意 DOM：每项由组件包一层 .ua-masonry-item，只有这一层带 break-inside: avoid，卡片才不会在列边界断成两截
2. 不传 columns 就按 minColumnWidth 自适应：容器变宽自动加列、窄屏自动降到一列，不需要自己监听 window.resize
3. 要固定几列（比如大屏看板恒 4 列）就传 columns，此时 minColumnWidth 不再生效——两者互斥，别同时传指望它俩协商
4. 间距传一次就够：gap 同时给 column-gap 与每项的 margin-bottom，列间距和行间距天然一致；改间距只改这一处
5. 列高不等才是瀑布流的价值所在（图片墙、评论流、看板）：等高内容请用 grid，瀑布流会把等高卡片铺成奇怪的错位
6. 什么时候别用：需要严格的行对齐（表格化数据）、需要按列独立滚动（用 splitter / 多个 virtualList），以及首屏内容需要按阅读顺序严格横向（多列布局是竖向填充，读屏顺序与视觉列序一致但不等同于行序）

现场演示：apps/docs/demo.html#sec-masonry

API 速览：

- button(props)：按钮，variant 三档视觉强度，disabled / loading 支持布尔或信号动态同步（loading 转圈并禁点），onClick 点击回调
- textField(props)：文本输入，value 信号双向绑定，disabled 支持布尔或信号，onInput 输入回调；prefix/suffix 可选渲染为输入框内侧附加块，clearable 开启后有值即显一键清空按钮，maxCount 设原生上限并实时显示字数
- inputNumber(props)：数字输入，−/+ 按钮与键盘 ↑↓ 调节（长按连发步进），min/max/step 夹取，precision 小数位归一；prefix/suffix 可选渲染为整组前后附加块
- textarea(props)：多行输入，value 信号双向绑定，disabled 支持布尔或信号，rows 控行数；prefix/suffix 可选渲染为上下附加块，maxCount 显示实时字数并设原生上限
- slider(props)：滑块，value 信号双向绑定，min/max/step 可配，原生 range 承载键盘交互，右侧可显示当前值
- checkbox(props)：复选框，checked 信号双向绑定，indeterminate 半选态（读屏播报 mixed），disabled 支持布尔或信号禁用
- switchButton(props)：开关，checked 信号双向绑定，loading 加载锁（滑块转圈 + aria-busy），disabled 支持布尔或信号禁用
- select(props)：自绘下拉选择（非原生 select），value 信号双向绑定，点外部 / Esc 关闭，选项弹层带微动画，disabled 支持布尔或信号禁用，空间不足自动翻转，键入字母快跳选项；searchable 开启弹层搜索框，clearable 有值时给清空按钮
- menu(props)：下拉菜单，触发器 + 自绘弹层，条目支持禁用 / 危险 / 分隔线，↑↓ 环形导航，点外部 / Esc 关闭，空间不足自动翻转，键入字母快跳菜单项
- flip(trigger, panel, preferred)：浮层上下翻转判定，按触发器与面板的视口几何决定 preferred 方位放不下时是否翻转，返回实际生效方位 'top' | 'bottom'
- radioGroup(props)：单选组，options + value 信号双向绑定，disabled 整组禁用
- accordion(props)：折叠面板，标题按钮 + grid 行高过渡，单开（手风琴）或多开，支持禁用面板与 active 信号受控
- breadcrumb(props)：面包屑，nav + ol 语义结构，末项为当前页（aria-current），有 href 走原生链接、无 href 交给 onNavigate
- card(props, ...children)：卡片容器
- badge(props)：徽标，variant = default | success | warning | danger
- tag(props)：可关闭标签，variant 五档语义色，closable + onClose 支持从数据源移除（纯展示请用 badge）
- avatar(props)：头像，有 src 渲染图片（alt 用名称）、无 src 用首字占位，size 可调 + 四档语义底色
- alert(props)：提示条，variant 四档语义色 + 可选 title 与 closable 关闭按钮，role=alert 读屏可播报
- toast.info/success/warning/error(message, duration?)：消息提示工厂，右上角堆叠，默认 3s 自动消失（悬停暂停、移出续时），出入场微动画；showToast(props) 为完整入参版本（closable 显示关闭按钮，同屏最多 4 条）
- tooltip(props, trigger)：包裹式悬浮提示，hover / 键盘聚焦延迟出场，Esc 收起；触摸屏按下即出、抬手延迟 1.2s 收起，气泡 pointer-events: none 不挡点击
- popover(props, trigger)：可交互气泡卡片，包裹触发器，默认点击开合 + 点外部 / Esc 收起，content 可放按钮等交互元素；视口一侧空间不足时自动翻转
- skeleton(props)：骨架屏占位，variant = text | rect | circle，text 支持 rows 多行，扫光微动画
- pagination(props)：独立分页器，page 信号双向绑定，页码过多折叠省略号，首末页禁用翻页，jumpable 时末尾可输入页码直接跳转，键盘可达；pageSizes 可切每页条数
- spinner(props)：加载指示器，size 控制直径
- progress(props)：进度条，value 信号驱动 0-100（满格切 success 色）；不传 value 退化为不确定态无限往复
- tabs(props)：标签页，active 信号双向绑定，滑动指示条 + 方向键环形导航，支持禁用页签；页签项 closable 时带关闭按钮（聚焦后 Delete 同样可关），关闭只回调 onClose 由调用方摘数据
- stepper(props)：步骤条，done/current/upcoming 三态，active 信号双向绑定，clickable 时已完成步骤可回跳
- rate(props)：评分星标，value 信号双向绑定，hover 预览移出还原，键盘方向键调节，max 可配，allowHalf 开启半星（左半区 0.5 分）
- empty(props)：空状态占位，title/description 可选，action 放操作区
- divider(props)：分隔线，水平/垂直两种方向，可带居中文案标签，dashed 切虚线
- space(props)：间距容器，flex gap 统一子元素间距，direction / size / align / justify / wrap / block 可调
- modal(props, ...children)：弹窗，open 信号驱动显隐，Esc / 遮罩 / × 关闭，size 三档宽度，footer 提供底部操作区
- drawer(props, ...children)：抽屉，open 信号驱动侧滑面板，placement 左右可配、width 可调、footer 操作区，遮罩 / × / Esc 关闭
- form(props, items)：表单容器，布局 + 聚合校验，全部通过才触发 onSubmit
- formItem(props, control)：表单字段，label + 必填红星 + 规则校验 + 错误区，返回 { el, validate } 句柄
- dataTable(props)：数据表格，粘性表头 / 行选择（全选三态）/ 排序 / 筛选 / 分页 / 空状态；loading 为真时数据行换骨架行且筛选/翻页禁用，内核为 table-core
- image(props)：图片，缩略图点击 / 回车开遮罩大图预览（Esc 或点击遮罩关闭），width 控制缩略宽，加载失败自动落占位（fallback 自定义文案）
- timeline(props)：时间线，items 按序渲染纵向事件流（标题 / 时间 / 正文 + 语义色节点），mode 控制轴线左右，pending 追加进行中节点
- segmented(props)：分段控制器，胶囊分段 + 滑动滑块，value 信号双向绑定，←→ 环形导航，整组或单项可禁用
- descriptions(props)：描述列表，items(label/value/span) 铺进 column 列网格，bordered 切表格形态，窄屏自动单列
- statistic(props)：统计数值，标题 + 大号数字 + 前后缀 + 环比趋势，value 变化时 rAF 滚动过渡（400ms easeOutCubic），减弱动效偏好下直落终值
- list(props)：列表，header/footer 外壳 + 条目 avatar/title/description/extra，onItemClick 让条目可点击，items 为空回落空状态
- carousel(props)：轮播，items 横排位移，指示点/箭头/←→ 键切换，autoplay 毫秒自动前进且悬停暂停，index 信号可外部受控
- tree(props)：树形，递归节点 + 箭头展开/收起 + 点选节点，键盘 Enter 选中、→ 展开、← 收起，禁用节点不响应
- upload(props)：文件选择，点击或拖拽入列，清单可逐个移除，onChange 回传文件数组（不内置上传请求）
- notification(props)：右下角通知卡片（标题 + 描述 + 操作区），返回 { el, close } 句柄，缺省常驻需手动关闭
- result(props)：结果页，status 四档语义色 + 标题 + 说明 + 操作区，用于提交完成/校验失败等终态
- transfer(props)：穿梭框，左右两栏勾选后整体搬运，targetKeys 信号双向绑定，两栏各自搜索与全选（含半选），禁用条目不参与搬运也不可勾选
- cascader(props)：级联选择，多列联动面板（悬停下钻 / 点击提交），value 为路径数组信号，changeOnSelect 允许停在中间层，全键盘可操作
- splitter(props)：可拖拽分栏容器，分隔条支持指针拖拽 / 方向键微调 / 双击复位，sizes 为百分比数组信号
- autoComplete(props)：输入联想，输入即过滤候选、↑↓ 环形高亮 / Enter 选中 / Esc 收起，options 可传函数做远程联想
- datePicker(props)：日期选择，单月网格 + 翻月 / 今天快捷键，value 为 YYYY-MM-DD 信号，min/max 闭区间限制，方向键逐日逐周可导航
- backTop(props)：返回顶部悬浮按钮，越过阈值才浮现，外环 conic-gradient 显示已滚比例，点击平滑回顶
- colorPicker(props)：颜色选择，预设色板（缺省语义令牌色）+ 原生取色器 + 手输 CSS 颜色串，value 为颜色串信号
- tour(props)：引导漫游，返回 { el, start, close } 句柄；聚光框按 target 选择器定位，卡片自动选方位，Esc / 跳过走 onClose
- watermark(props, ...children)：内容容器 + 平铺水印覆盖层，canvas 生成单元、颜色取自令牌，覆盖层不挡交互
- checkboxGroup(props)：多选组，value 为数组信号，min/max 用禁用边界项实现（不抛错），checkAll 支持半选且只统计未禁用项
- affix(props, ...children)：固钉，滚过 offset 后内容转 fixed 贴顶、外层保留占位高度，固定后宽度随容器自适应
- multiSelect(props)：多选下拉，标签式回显 + 可搜索弹层，点选不收起便于连续多选，max 到限后未选项禁用
- anchor(props)：锚点导航，竖排区块链接，滚动时按目标位置高亮当前区块（aria-current 同步），点击按 offset 偏移量平滑定位并回写 hash；container 可指定内滚容器
- popconfirm(props, trigger)：二次确认气泡，挂在任意触发器上，就地确认危险操作；确定后先收起再回调，取消 / 点外部 / Esc 走 onCancel，展开即聚焦「取消」
- calendar(props)：月历，整月网格内嵌页面（排期 / 值班 / 日程），每日可带 marker 标记文案，方向键漫游 + Enter 选中，翻月走 onMonthChange
- typography(props)：排版文本，variant 决定语义（标题出 h1-h5 / 段落出 p / 行内出 span），支持语义色、装饰位、对齐、多行截断与复制按钮
- grid(props, ...children)：栅格容器，columns 固定列数或 min 走 auto-fit 自适应列宽，gutter 统一列间距，窄屏自动降为单列
- floatButton(props)：悬浮操作按钮，固定停靠左下 / 右下角，label 缺省为纯图标圆形，badge 走角标，tooltip 兼作 aria-label
- mentions(props)：@提及输入，textarea 内键入 @ 弹候选（名字 / 关键词过滤），↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层，选中按光标替换并写回 value
- treeSelect(props)：树形选择，按钮触发弹层并复用 tree（搜索 / 多选 / 级联），单选选中即收起、多选保持展开，Esc 与点外收起
- link(props)：链接，站内跳转与站外跳转统一入口，external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报
- avatarGroup(props)：头像组，横向叠放并在超出 max 时折叠成 +N，整组 title 列出全部姓名
- virtualList(props)：虚拟列表，固定行高长列表只渲染可视窗口 + 上下缓冲条，万级数据滚动不随总量掉帧；返回 { el, scrollTo } 句柄
- commandPalette(props)：命令面板，⌘K / Ctrl+K 唤起，输入即过滤（主文案/说明/关键词三层），↑↓ 环形选择 + 回车执行 + Esc 收起，选中与关闭都归还焦点；返回 { el, open, close } 句柄
- timePicker(props)：时间选择，时 / 分两列弹层，value 信号双向绑定 HH:mm，分钟可配步长，Esc 收起
- pinInput(props)：验证码输入，N 格单字符数字输入，输入自动进格、退格回删、方向键移格、粘贴自动分发，value 信号双向绑定，mask 可密文显示
- copyButton(props)：复制按钮，点击把 text 写入剪贴板并短暂显示已复制，text 可传函数取实时值，clipboard API 不可用时降级 execCommand
- countdown(props)：倒计时，秒级递减到 0 自动停表并触发 onFinish，缺省 mm:ss 展示，返回句柄 start/stop/reset
- kbd(props)：键盘按键标记，键帽样式展示单个按键或组合键（多键以 + 连接），用于快捷键提示
- highlight(props)：关键词高亮，命中片段包 mark（大小写不敏感全部命中），text / keyword 支持信号响应式刷新
- inputTag(props)：标签输入，输入后回车 / 逗号成标签、chip 可单个删除、空框退格删末个，value 信号双向绑定，max 满额自动禁输
- layout(props, ...children)：布局骨架，页头 / 侧栏 / 内容 / 页脚四区一次成型，侧栏可折叠（collapsed 信号双向绑定）
- jsonViewer(props)：JSON 树查看，按层折叠、分支带计数摘要、叶子按类型着色，只读不编辑
- diffView(props)：文本差异对比，行级 LCS 对齐 + 增删着色 + 双行号列，顶部带 +N / −N 统计
- codeBlock(props)：代码块，等宽多行 + 行号列 + 标题行 + 一键复制原文，只展示不高亮
- signaturePad(props)：手写签名板，指针绘制 + DPR 位图，返回 handle（el / clear / toDataURL / isEmpty）
- loading(props, ...children)：区域加载遮罩，内容原地降透明度 + 转圈文案，带延迟出场避免快请求闪烁
- relativeTime(props)：把时间戳渲染成「3 分钟前」并自动刷新，超阈值改显示绝对日期，title 恒给精确值
- sparkline(props)：迷你趋势线，把数值序列压成折线（可填充 + 末点标记），无坐标轴无交互
- heatmap(props)：日历热力图，按周列 × 星期行铺格子、四档浓度表示当天数量，悬停看明细
- progressRing(props)：环形进度，SVG 双圆靠 dasharray 画弧 + 中心百分比，value 可传信号自动重绘
- noticeBar(props)：单行通知条，语义色 + 左侧符号 + 可选跑马灯与关闭，适合页顶常驻播报
- sortableList(props)：拖拽排序列表，整行可拖 + Alt+↑↓ 键盘搬位，返回 { el, move, order } 句柄
- masonry(props)：瀑布流多列布局，列宽自适应或固定列数，卡片不被拆到两列

一行用法：

```
const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')
```

约定：

- 皮肤不持有业务状态，一律委托 headless 内核或调用方信号
- 表单类组件统一约定：*Props.value/checked/active 为 Signal 双向绑定，onChange/onInput 为回调
- 语义色 variant 四档：default|info / success / warning / danger，一律来自令牌，禁止硬编码颜色
- 无障碍基线：装饰性占位（skeleton）aria-hidden、加载指示（spinner）role=status、关闭按钮必须 aria-label、弹层弹窗 aria-haspopup/expanded、读屏可见的状态一律同步 aria-*

---

## @ui-arch/frame（headless-core）v0.1.0

零依赖微框架：组件 = 返回 DOM 的普通函数。signal/effect/computed 管状态，h() 管 DOM，无虚拟 DOM 无生命周期抽象。

API 速览：

- signal(initial) -> Signal<T>：无参读值并收集依赖，传参写值并触发订阅
- effect(fn)：立即执行并追踪依赖，信号变化自动重跑
- computed(fn)：由信号派生的只读计算值
- h(tag, props, ...children)：hyperscript；on* 绑事件，函数值属性/子节点自动响应式
- renderList(el, fn)：信号变化时整体替换容器子节点
- mount(target, view)：挂载视图

一行用法：

```
const count = signal(0); mount('#app', () => h('button', { onClick: () => count(count() + 1) }, () => `count = ${count()}`))
```

约定：

- 组件 = 返回 DOM 节点的普通函数，不做类组件与生命周期抽象
- 跨系统状态（如 table-core）用桥接信号：subscribe 里递增 version 信号

---

## @ui-arch/table-core（headless-core）v0.1.0

Headless 表格内核：不渲染任何 DOM，只管理状态与派生。任何渲染层通过 getVisibleRows() 拿行模型，配 subscribe() 刷新。

API 速览：

- createTable(options) -> TableInstance: create headless table instance
- sortPlugin(options?) -> multi-column sorting plugin (asc -> desc -> off cycle)
- filterPlugin(options?) -> column filter plugin (default substring match, custom predicate supported)
- paginationPlugin() -> client-side pagination with page clamping, exposes setPage/nextPage/prevPage/getPageInfo
- selectionPlugin() -> rowKey-based selection, exposes selectAllVisible

一行用法：

```
const table = createTable({ data, columns, rowKey: 'id', plugins: [filterPlugin(), sortPlugin(), paginationPlugin()], initialState: { pagination: { page: 1, pageSize: 20 } } }); table.dispatch({ type: 'toggleSort', key: 'name' }); table.getVisibleRows()
```

约定：

- selection/expanded must store rowKey, never row index
- derive pipeline order is fixed: filter -> sort -> paginate
- actions are the only way to mutate state; state is immutable

---

## @ui-arch/tokens（design-tokens）v0.1.0

设计令牌唯一来源：CSS 变量 --ua-* + 同名 JSON。皮肤只允许消费变量，禁止硬编码颜色；暗色只换值不换名。

API 速览：

- tokens.css: import '@ui-arch/tokens/tokens.css' once at app root
- tokens.json: programmatic access for tools and agents

一行用法：

```
import '@ui-arch/tokens/tokens.css'; then use var(--ua-color-accent) etc. in any skin package
```

---

## @ui-arch/cli（tooling）v0.1.0

工程层工具包（private，不发布）。所有 Agent 与人都通过它读取组件库：清单驱动索引、文档、MCP 三件套，外加 style / verify 两道门禁。

API 速览：

- ua doctor：11 项环境与自洽预检（node / 依赖 / git 钩子与身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现），任一失败退出码 1
- ua index：扫描 packages/ 两层目录的 ua.manifest.json，生成 .ua/index.json 与 llms.txt
- ua list：列出注册表内所有包与组件数；--json 输出「包 + 组件 id」结构化数组，--components 逐组件展开
- ua info <包名|组件id>：包名返回摘要，组件 id 返回 props 表 + 步骤 + 示例；--json 输出原始清单，--md 直接给可粘贴的 Markdown 片段（标题 + 参数表 + 用法 + 示例）
- ua tokens [关键词] [--json|--unused]：打印 --ua-* 令牌清单（颜色带暗色值）；--unused 反向列出皮肤包与令牌自身都没引用的令牌，用来收口令牌面
- ua docs：生成人类文档站 apps/docs/index.html + demo.html 与 Agent 全量文档 llms-full.txt
- ua style：注释规范门禁（导出必须 /*** ***/、接口字段行尾注释、行注释中文），违规退出码 1
- ua verify：清单一致性门禁（id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在 / 视觉回归抽查清单六组件各 ≥3 条且五维度齐全）
- ua commit-lint <文件|message>：提交信息门禁（type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符），规范见 COMMITTING.md，违规退出码 1
- ua audit:visual [--json|--baseline]：视觉漂移门禁，统计皮肤包里各类硬编码视觉字面量（字号/动效时长/阴影/圆角/焦点环/内距/控件高度/字重/行高/层级/边框色）并与 .ua/visual-baseline.json 棘轮比对，超基线退出码 1；--baseline 重写基线，--json 出机器可读计数与每类命中行号（line / prop / value / hint，Agent 可直接定位）
- ua audit:contrast [--json|--list]：对比度门禁，把令牌里 18 组「前景色 × 底色」配对按 WCAG 公式算成对比度（求值支持 var() 与 color-mix，半透明底先压到页面底色），亮/暗 × 常规/高对比四套分算，低于阈值（正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3）退出码 1；--json 出逐对明细，--list 只列配对表
- ua audit:motion [--json]：动效兜底门禁，两类必查——① 带 infinite 的动画逐个核对是否在 prefers-reduced-motion 块里 animation: none；② 悬停 / 按压的位移（translate）是否在减少动效下归零（认 ua- 类通配的全局规则）。缺兜底退出码 1（--json 出逐条明细）
- ua audit:surface [--json|--list]：表面阶梯门禁，逐档核对底色对底色的亮度比（页面底 → 表面 → 悬停底 → 按下底 ≥1.04、凹陷底 ≥1.02），亮/暗 × 常规/高对比四套主题各算一遍，不达标退出码 1；对比度只管文字读不读得清，管不到「悬停有没有反馈」（实测过 1.008 的悬停底）
- ua mcp：零依赖 stdio MCP server，暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe；get_component 支持 format: md 直接取可粘贴的 Markdown 片段
- ua loop [--json]：迭代循环诊断，一眼看清锁心跳（是否 15 分钟内）、上次留痕、backlog 待办与视觉欠账数，并给出本轮该不该抢锁、下一条该选谁；多实例错位接力时不必翻调度器数据库

一行用法：

```
node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id> [--json|--md]|docs|tokens [--json|--unused]|style|verify|commit-lint|audit:visual|audit:contrast|audit:motion|doctor|mcp>；仓库内等价于 npm run index / docs / style / verify / audit:visual / audit:contrast / audit:motion / doctor
```

约定：

- 零依赖：只用 node 标准库，Node >= 18
- 清单是单一来源：代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生
- 门禁只增不改：新增校验必须能被人手复现，报错要指出文件与字段

---

## 设计令牌（--ua-*）

- --ua-color-bg: #ffffff（暗色 #101216）
- --ua-color-surface: #f6f7f9（暗色 #1a1c22）
- --ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface))（暗色 color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface))）
- --ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface))（暗色 color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface))）
- --ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))（暗色 color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg))）
- --ua-color-text: #1c1e21（暗色 #e7e9ec）
- --ua-color-text-muted: #676d78（暗色 #888780）
- --ua-color-text-secondary: #4b5158（暗色 #b4bac2）
- --ua-color-text-faint: #9aa0a8（暗色 #62676f）
- --ua-color-overlay: rgba(0, 0, 0, 0.45)（暗色 rgba(0, 0, 0, 0.6)）
- --ua-color-overlay-strong: rgba(0, 0, 0, 0.6)（暗色 rgba(0, 0, 0, 0.7)）
- --ua-color-border: rgba(0, 0, 0, 0.15)（暗色 rgba(255, 255, 255, 0.15)）
- --ua-color-scrollbar: rgba(0, 0, 0, 0.22)（暗色 rgba(255, 255, 255, 0.24)）
- --ua-color-scrollbar-hover: rgba(0, 0, 0, 0.38)（暗色 rgba(255, 255, 255, 0.42)）
- --ua-color-highlight: rgba(255, 255, 255, 0.5)（暗色 rgba(255, 255, 255, 0.08)）
- --ua-color-accent: #185fa5（暗色 #4a9aeb）
- --ua-color-accent-contrast: #ffffff（暗色 #042c53）
- --ua-color-accent-soft: color-mix(in srgb, var(--ua-color-accent) 72%, var(--ua-color-bg))（暗色 color-mix(in srgb, var(--ua-color-accent) 82%, var(--ua-color-bg))）
- --ua-color-tint-accent: color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-soft), transparent)（暗色 color-mix(in srgb, var(--ua-color-accent) 14%, transparent)）
- --ua-color-tint-accent-weak: color-mix(in srgb, var(--ua-color-accent) 5%, transparent)（暗色 color-mix(in srgb, var(--ua-color-accent) 8%, transparent)）
- --ua-color-danger: #a32d2d（暗色 #d96a6a）
- --ua-color-success: #0f6e56（暗色 #4fc39e）
- --ua-color-warning: #9a6b1f（暗色 #d9a94f）
- --ua-alpha-soft: 10%
- --ua-alpha-tint: 18%
- --ua-alpha-line: 30%
- --ua-alpha-mid: 45%
- --ua-alpha-strong: 55%
- --ua-alpha-heavy: 70%
- --ua-radius-sm: 4px
- --ua-radius-md: 8px
- --ua-radius-lg: 12px
- --ua-radius-md-inset: calc(var(--ua-radius-md) - 1px)
- --ua-space-1: 4px
- --ua-space-2: 8px
- --ua-space-3: 12px
- --ua-space-4: 16px
- --ua-space-05: 2px
- --ua-space-15: 6px
- --ua-space-25: 10px
- --ua-space-35: 14px
- --ua-space-table-cell: var(--ua-space-2) var(--ua-space-3)
- --ua-space-6: 24px
- --ua-space-indent: 22px
- --ua-font-size-xs: 11px
- --ua-font-size-sm: 12px
- --ua-font-size-md: 13px
- --ua-font-size-lg: 14px
- --ua-font-size-xl: 16px
- --ua-font-size-2xl: 18px
- --ua-font-size-3xl: 22px
- --ua-font-size-4xl: 26px
- --ua-font-size-5xl: 34px
- --ua-font-size-scale-md: 0.92em
- --ua-font-size-scale-sm: 0.85em
- --ua-tracking-wide: 0.04em
- --ua-tracking-tight: -0.01em
- --ua-weight-normal: 400
- --ua-weight-medium: 500
- --ua-weight-semibold: 600
- --ua-weight-bold: 700
- --ua-leading-none: 1
- --ua-leading-tight: 1.35
- --ua-leading-normal: 1.55
- --ua-border-subtle: color-mix(in srgb, var(--ua-color-border) 60%, transparent)
- --ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06)（暗色 0 1px 2px rgba(0, 0, 0, 0.3)）
- --ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.1)（暗色 0 4px 12px rgba(0, 0, 0, 0.4)）
- --ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.12)（暗色 0 10px 28px rgba(0, 0, 0, 0.45)）
- --ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.14)（暗色 0 18px 44px rgba(0, 0, 0, 0.5)）
- --ua-shadow-accent: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent)（暗色 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 30%, transparent)）
- --ua-shadow-accent-strong: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent)（暗色 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)）
- --ua-shadow-highlight: inset 0 1px 0 var(--ua-color-highlight)
- --ua-shadow-hairline: inset 0 -1px 0 var(--ua-border-subtle)
- --ua-shadow-inset-border: inset 0 0 0 1px var(--ua-color-border)
- --ua-shadow-inset-accent: inset 0 0 0 1px var(--ua-color-accent)
- --ua-shadow-gap: 0 0 0 2px var(--ua-color-bg)
- --ua-duration-fast: 0.15s
- --ua-duration-base: 0.2s
- --ua-duration-slow: 0.25s
- --ua-duration-slower: 0.35s
- --ua-duration-loop-fast: 0.6s
- --ua-duration-loop-mid: 0.8s
- --ua-duration-loop-slow: 1.2s
- --ua-duration-loop-long: 1.6s
- --ua-duration-step: 0.02s
- --ua-ease: cubic-bezier(0.2, 0.8, 0.2, 1)
- --ua-ease-out: cubic-bezier(0.16, 1, 0.3, 1)
- --ua-ease-spring: cubic-bezier(0.34, 1.3, 0.5, 1)
- --ua-translate-1: 1px
- --ua-translate-2: 2px
- --ua-translate-3: 3px
- --ua-translate-4: 4px
- --ua-translate-6: 6px
- --ua-translate-8: 8px
- --ua-translate-12: 12px
- --ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent)（暗色 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 34%, transparent)）
- --ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)（暗色 inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 52%, transparent)）
- --ua-focus-ring-warning: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent)（暗色 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 34%, transparent)）
- --ua-focus-ring-danger: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 18%, transparent)（暗色 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 30%, transparent)）
- --ua-focus-ring-pulse: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent)（暗色 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 8%, transparent)）
- --ua-focus-ring-selected: 0 0 0 1.5px var(--ua-color-accent-soft)
- --ua-focus-outline: 2px solid var(--ua-color-accent)
- --ua-focus-outline-inset: 1.5px solid var(--ua-color-accent-soft)
- --ua-focus-outline-soft: 2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent)
- --ua-control-h-sm: 28px
- --ua-control-h-md: 34px
- --ua-control-h-lg: 40px
- --ua-z-raised: 10
- --ua-z-dropdown: 100
- --ua-z-sticky: 200
- --ua-z-overlay: 300
- --ua-z-modal: 400
- --ua-z-toast: 500

皮肤包只允许消费这些变量：写死 #hex 会被 verify 拦截，拼错变量名会被令牌引用校验拦截。


# 组合配方（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`），所以「置顶 / 上移」按钮不必另写状态同步；越界下标静默忽略，不用自己判边界。
- 拖到边缘不会自动滚动：条目多到一屏放不下时改用筛选或上下移按钮，不要指望拖出视口；跨列表搬运用两个实例各持一个信号。
- 条目文案要唯一——重名会让「谁被搬走了」失去参照，需要重复内容时把序号拼进文案（`'确认条款 #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 的双源一致性校验会直接报错。
