weifuwu 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ # 自定义组件开发指南
2
+
3
+ > 用 weifuwu/client 写自己的组件——与内置组件同权:同渲染引擎、同弹层原语、同类型安全。
4
+ > 前置:[前端概念](frontend.md)(两阶段模型/ctx.ui)+ [组件列表](components.md)。
5
+
6
+ ---
7
+
8
+ ## 0. 最小骨架
9
+
10
+ ```tsx
11
+ import { h, type Component } from 'weifuwu/client'
12
+
13
+ // Component<P, C>:P = props(JSX 自动推断),C = ctx 注入依赖(默认 {})
14
+ const Badge: Component<{ text: string; color?: string }> = () =>
15
+ (props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
16
+ ```
17
+
18
+ - 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => VNode` 每次渲染执行
19
+ - 不需要状态就不调 `ctx.ui.$()`;需要交互才用
20
+
21
+ ## 1. 有状态组件
22
+
23
+ ```tsx
24
+ const Toggle: Component = (_init, ctx) => {
25
+ const $ = ctx.ui.$() // 深度 Proxy:赋值自动触发渲染(微任务批量)
26
+ $.on = false
27
+
28
+ return (props) => h('button', {
29
+ class: 'my-toggle',
30
+ onClick: () => $.on = !$.on,
31
+ }, $.on ? '开' : '关')
32
+ }
33
+ ```
34
+
35
+ | 状态类型 | 存放位置 | 触发渲染 |
36
+ |---------|---------|---------|
37
+ | 自动 UI 状态 | `$.xxx` | 赋值自动(微任务) |
38
+ | 手动 UI 状态 | 闭包 `let` | 需 `ctx.ui.render()` |
39
+ | 内部缓存 | 闭包 `let` | 不触发 |
40
+
41
+ ## 2. 带弹层的组件(最高频的自定义场景)
42
+
43
+ 用 `ctx.ui.usePopup`——一个组合器收敛 open 状态 + 触发(hover→tap 降级/longpress)+ Escape + 外部点击 + 定位/视口 clamp + portal:
44
+
45
+ ```tsx
46
+ const MyPopover: Component<{ content: string }> = (_init, ctx) => {
47
+ const $ = ctx.ui.$()
48
+ $.open = false
49
+ let wrapEl: HTMLElement | null = null
50
+ const wrapRef = (el: HTMLElement | null) => { wrapEl = el }
51
+
52
+ const popup = ctx.ui.usePopup({
53
+ trigger: 'hover', // 触屏自动降级 tap(useHoverCapable 内部判定)
54
+ el: () => wrapEl, // 锚点
55
+ isOpen: () => $.open,
56
+ setOpen: (v) => { $.open = v }, // $ 赋值自动渲染
57
+ width: 320, // 自动 clamp 视口
58
+ closeOnOutside: true, // 外部点击关闭(默认)
59
+ closeOnEscape: true, // Escape 关闭(默认,document 级——portal 焦点也生效)
60
+ })
61
+
62
+ return (props) =>
63
+ h('span', { class: 'anchor', ref: wrapRef, ...popup.wrapProps },
64
+ props.children,
65
+ popup.portal(h('div', { class: 'wf-panel' }, props.content)),
66
+ )
67
+ }
68
+ ```
69
+
70
+ > **受控弹层**:传 `open` getter + `onOpenChange` 即受控(父组件独占开关);缺 onOpenChange 时 usePopup 内部 warn 提示。
71
+
72
+ ## 3. 对话框类组件(Modal 系)
73
+
74
+ 全屏对话框(焦点 trap + 滚动锁 + 退场动画)不在 usePopup 范围——用 **`ctx.ui.useDialog`** 组合器(Modal/Drawer 同款:退场状态机 + 滚动锁 + 焦点 trap + animationend 卸载):
75
+
76
+ ```tsx
77
+ import { createPortal } from 'weifuwu/client'
78
+
79
+ const MyDialog: Component<{ open: boolean; onClose: () => void }> = (_init, ctx) => {
80
+ const dialog = ctx.ui.useDialog({ name: 'MyDialog' }) // mount 创建
81
+
82
+ return (props) => {
83
+ const phase = dialog.sync(!!props.open) // render 同步 open
84
+ if (phase === 'closed') return null
85
+
86
+ return createPortal(h('div', {
87
+ class: 'wf-overlay',
88
+ onClick: (e: any) => { if (e.target === e.currentTarget) props.onClose() },
89
+ }, h('div', {
90
+ class: `wf-modal ${phase === 'exit' ? 'wf-modal--exit' : 'wf-modal--enter'}`,
91
+ ref: dialog.panelRef, // 焦点 trap 目标
92
+ onKeyDown: (e: any) => { if (e.key === 'Escape') props.onClose() }, // Escape 语义组件层
93
+ }, props.children)), document.body)
94
+ }
95
+ }
96
+ ```
97
+
98
+ > `dialog.rootRef` 挂到 portal 根(lockScroll + animationend 退场监听);`panelRef` 挂到面板(trapFocus)。
99
+ > 低层原语 `trapFocus`/`lockScroll`/`animateOut` 仍从 `weifuwu/client` 导出(特殊场景组装用)。
100
+
101
+ ## 4. AI 组件
102
+
103
+ 会话语义由 `ctx.ui.useChat` 提供(消息/流式/工具/审批/stop/retry 全封装),返回的 handle 与 `$` 同一容器:
104
+
105
+ ```tsx
106
+ const ChatPanel: Component = (_init, ctx) => {
107
+ const $ = ctx.ui.useChat({
108
+ url: '/api/chat',
109
+ approveUrl: '/api/approve', // HITL 审批上行(缺省 approve() 只清卡片)
110
+ body: (messages) => ({ messages, mode: 'agent' }),
111
+ })
112
+
113
+ return () => h('div', { class: 'chat' },
114
+ $.messages.map((m) => h('div', { class: `msg-${m.role}` }, m.content)),
115
+ h('input', { value: $.input, onInput: (e: any) => $.input = e.target.value }),
116
+ h('button', { onClick: () => $.send() }, $.streaming ? '…' : '发送'),
117
+ )
118
+ }
119
+ ```
120
+
121
+ **共享 `$` 给子组件**(如 `<AiChat chat={$} />`):父组件 dirty 不驱动子组件(三态 skip),子组件 mount 期 `initProps.chat.__watch?.(() => ctx.ui.dirty())` 自订阅。
122
+
123
+ ## 5. 异步组件(数据声明在工厂层)
124
+
125
+ ```tsx
126
+ import { asyncComponent } from 'weifuwu/client'
127
+
128
+ const UserCard = asyncComponent(async (ctx) => {
129
+ const user = await ctx.data.get(`/api/user/${ctx.route.params.id}`) // 工厂层取数(SSR 序列化进 HTML)
130
+ return (_init, ctx) => {
131
+ const $ = ctx.ui.$()
132
+ $.liked = false
133
+ return (props) => h('div', {}, user.name, h('button', { onClick: () => $.liked = !$.liked }))
134
+ }
135
+ })
136
+ ```
137
+
138
+ - 工厂只执行一次(WeakMap 缓存);客户端首次渲染占位 → resolve 后整树补全;服务端直接 await(无占位)
139
+ - **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `$` + fetch
140
+
141
+ ## 6. 类型纪律(编译期防线)
142
+
143
+ ```tsx
144
+ const Badge: Component<{ variant: 'primary' | 'muted' }> = () =>
145
+ (props) => h('span', { class: `badge-${props.variant}` }, props.children)
146
+
147
+ // 负例:variant 传错 → tsc 报错(@ts-expect-error 是类型流测试的写法)
148
+ // @ts-expect-error variant 不允许 'bogus'
149
+ const bad: { variant: 'primary' | 'muted' } = { variant: 'bogus' }
150
+
151
+ // ctx 注入声明(C 泛型):声明了才能用,未声明编译期报错
152
+ const Page: Component<{}, { api: ApiInjected['api'] }> = (_init, ctx) => {
153
+ ctx.api.get('/x')
154
+ return () => null
155
+ }
156
+ ```
157
+
158
+ | 规则 | 违反后果 |
159
+ |------|---------|
160
+ | `Component<P, C>` 类型化(禁 `_init: any`) | 编译期不可查 |
161
+ | 受控 props 必须配回调 | 交互静默失效(组件 console.warn) |
162
+ | ref 用 `ctx.ui.useStableRef`(禁内联 ref) | 清理逻辑每次渲染误触 |
163
+ | 初始状态确定性(禁 `window.innerWidth` 直接初始化) | SSR/hydration mismatch |
164
+ | 小尺寸按钮固定 min/max-height | 被全局 36px 撑成竖条 |
165
+
166
+ ## 7. 测试写法
167
+
168
+ ```tsx
169
+ function renderVNode(Comp: any, props: any, ctx: any) {
170
+ const result = Comp(props, ctx)
171
+ return typeof result === 'function' ? result(props) : result
172
+ }
173
+
174
+ const ctx = { ui: { $: () => ({}), render: () => {}, dirty: () => {}, useControlled: (o: any) => ({ value: o.value, setValue: o.onChange ?? (() => {}), controlled: o.value !== undefined }) } }
175
+ const vnode = renderVNode(Toggle, {}, ctx)
176
+ // 断言 vnode 结构(子组件 VNode.type 是组件函数,不是标签名)
177
+ ```
178
+
179
+ - 类型流测试:`@ts-expect-error` 负例(见 [type-flow.test.ts](../src/components/type-flow.test.ts))
180
+ - 组件测试跑在 node --test;DOM 事件级测试需 `document.body.appendChild(container)`
181
+
182
+ ## 8. 受控组件标准
183
+
184
+ 新受控组件**必须**用 `ctx.ui.useControlled`(受控判定 + 缺回调 warn 一次 + 非受控内部状态跨渲染保持):
185
+
186
+ ```tsx
187
+ const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = (_init, ctx) => {
188
+ return (props) => {
189
+ const ctrl = ctx.ui.useControlled<boolean>({ value: props.active, onChange: props.onChange, name: 'CollapseItem' })
190
+ return h('button', {
191
+ onClick: () => ctrl.setValue(!(ctrl.value ?? false)), // 受控走 onChange;非受控内部状态
192
+ }, (ctrl.value ?? false) ? '开' : '关')
193
+ }
194
+ }
195
+ ```
196
+
197
+ > 多受控维度组件(Tree 的 expandedKeys+checkedKeys)不适用单值 useControlled——保留手工受控判定(参考 Tree.ts),warn 文案与 useControlled 保持一致。
198
+
199
+ ## 8.5 动画(4 层能力)
200
+
201
+ | 层 | 原语 | 用法 |
202
+ |----|------|------|
203
+ | CSS 语言 | Token(`--wf-dur-*`/`--wf-ease-*`/`--wf-motion-*`)+ `--enter`/`--exit` 成对 | 组件动画统一引用 Token,禁硬编码 |
204
+ | 生命周期 | `useAnimationEnd(cb, { once })`(完成回调)/ `usePresence({ name })`(显隐状态机)/ `animateOut(el, done)`(命令式退场) | 入场 settle / 退场延迟卸载 / 命令式播动画 |
205
+ | 数值驱动 | `useTween(target, { duration, ease })`(补间)/ `useInView` / `useScrollPosition` | count-up / 进入视口播 / 滚动联动 |
206
+ | 偏好感知 | `useReducedMotion()` | JS 动画(rAF/tween)侧跳过;CSS 动画 `_base.css` 已全局降级 |
207
+
208
+ **纪律**:组件内动画事件监听**唯一入口是 `useAnimationEnd`**——禁直接 `addEventListener('animationend')`(DatePicker 已收敛);退场优先 `usePresence`(声明式状态机)或 `animateOut`(命令式)。
209
+
210
+ ## 9. 样式纪律(style-audit 强制)
211
+
212
+ - 动效用 Token:`--wf-dur-*` / `--wf-ease-*` / `--wf-motion-*`(禁硬编码)
213
+ - 语义色用 `-text` 变体(700 级);实心填充文字用 `--wf-color-on-brand`;遮罩 `--wf-overlay`
214
+ - 图标用 `Icon` 组件(禁裸文本字形 ✕✓⚠▲)
215
+ - 触屏(coarse pointer)自动 44px 命中区
216
+
217
+ ---
218
+
219
+ ## 内置组件 = 最佳实践范本
220
+
221
+ | 想要的能力 | 参考源码 |
222
+ |-----------|---------|
223
+ | 弹层组合(hover/click/longpress) | [Tooltip.ts](../src/components/Tooltip/Tooltip.ts) / [ContextMenu.ts](../src/components/ContextMenu/ContextMenu.ts) |
224
+ | 对话框状态机 | [Modal.ts](../src/components/Modal/Modal.ts) / [Drawer.ts](../src/components/Drawer/Drawer.ts) |
225
+ | AI 会话 | [AiChat.ts](../src/components/AiChat/AiChat.ts) |
226
+ | 受控 + 键盘导航 | [Collapse.ts](../src/components/Collapse/Collapse.ts) / [Tabs.ts](../src/components/Tabs/Tabs.ts) |
227
+ | 异步数据 | [UserProfile](../src/components/Img/Img.ts) 的 factory 模式 |
228
+ | 命令式 API(toast/confirm) | [Toast.ts](../src/components/Toast/Toast.ts) |
229
+
230
+ ## 已知边界(诚实裁剪)
231
+
232
+ - `usePopup` 覆盖浮层(Tooltip/Popover/Dropdown/Mentions/Cascader/ContextMenu);**全屏对话框**(Modal/Drawer)用 useDialog(Escape 语义留组件层);Command/Img preview 保持独立实现
233
+ - **事件监听纪律**:组件库内部浏览器事件监听**统一走 `ctx.ui.useXXX`**——滚动/观察/弹层/对话框/快捷键/拖拽/DnD 全覆盖:
234
+ `useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup`(ContextMenu 自由定位)、`useDialog`(Modal/Drawer)、`useGlobalKey`(Command 快捷键/Img preview Escape)、`useDrag`(Resizable)、`useDragDrop`(FileUpload)、`useControlled`/`useStableRef`(状态/ref)
235
+ - 唯一保留:**DatePicker 元素级 `animationend`**(入场动画完成后坐标 settle——与 usePopup panelRef/motion.animateOut 同款框架基础设施,非浏览器全局事件)
236
+ - **Select/DatePicker** 是 inline/absolute 菜单(自适宽),不迁移 usePopup——菜单直接挂在锚点下
237
+ - `createReactiveState` 已导出:组件外建全局 store(`createReactiveState(() => {})` + `$.__watch(cb)` 订阅)
@@ -2,26 +2,50 @@
2
2
 
3
3
  > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
4
4
 
5
- | 变量 | 用途 | 模块 |
6
- |------|------|------|
7
- | `DATABASE_URL` | PostgreSQL 连接字符串 | `postgres()` |
8
- | `REDIS_URL` | Redis 连接字符串 | `redis()` |
5
+ ## 环境变量
6
+
7
+ | 变量 | 用途 | 模块 | 默认 |
8
+ |------|------|------|------|
9
+ | `DATABASE_URL` | PostgreSQL 连接字符串 | `postgres()` | —(必填) |
10
+ | `REDIS_URL` | Redis 连接字符串 | `redis()` | `redis://localhost:6379` |
11
+ | `AUTH_SECRET` | userSystem HMAC 签名密钥(≥16 字符) | `userSystem()` | 可传 `options.secret` |
12
+ | `DEEPSEEK_API_KEY` | LLM 对话 provider API key | `ai()` | — |
13
+ | `DEEPSEEK_BASE_URL` | LLM 对话 provider 端点 | `ai()` | `https://api.deepseek.com/v1` |
14
+ | `DEEPSEEK_MODEL` | 默认对话模型 | `ai()` | `deepseek-v4-flash` |
15
+ | `DASHSCOPE_API_KEY` | embedding 向量化 provider key | `ai({ embedding })` | — |
16
+ | `DASHSCOPE_BASE_URL` | embedding provider 端点 | `ai({ embedding })` | — |
17
+ | `DASHSCOPE_EMBEDDING_MODEL` | embedding 模型名 | `ai({ embedding })` | — |
18
+ | `RESEND_API_KEY` | 邮件 adapter `resend` | `email()` | 可用 `options.resend` |
19
+ | `SMTP_HOST` | 邮件 adapter `smtp` | `email()` | `localhost` |
20
+ | `SMTP_PORT` | SMTP 端口 | `email()` | `3025` |
21
+
22
+ > 均可通过中间件 options 显式传入(`postgres({ url })` / `userSystem({ secret })` / `ai({ provider })`),环境变量为默认来源。
9
23
 
10
24
  ---
11
25
 
12
- # 开发命令
26
+ ## 开发命令
13
27
 
14
28
  ```bash
15
29
  npm run build # 构建 dist/
16
30
  npm run typecheck # TypeScript 类型检查
17
- npm test # 运行 node --test
18
- node scripts/release.mjs <version> # 发布
31
+ npm test # 运行 node --test(含 docker 真库测试)
32
+ node scripts/release.mjs <version> # 构建 + 发布 + git tag
19
33
  ```
20
34
 
21
35
  ```bash
22
- # 测试前启动依赖服务
36
+ # 测试前启动依赖服务(postgres / redis / smtp)
23
37
  docker compose up -d
24
38
  ```
25
39
 
26
40
  ---
27
41
 
42
+ ## 应用示例启动
43
+
44
+ ```bash
45
+ # 组件 cheatsheet(零依赖)
46
+ cd apps/components-demo && node server.ts
47
+
48
+ # 全栈 SaaS 示例(多租户 AI 平台)
49
+ cd apps/agent-platform && npm run seed && npm run dev
50
+ # 凭据:admin@demo.com / admin123
51
+ ```
package/docs/frontend.md CHANGED
@@ -153,6 +153,18 @@ ctx.ui.render(['name'])
153
153
  | `render()` | `render(ids?: string[])` | 同步强制渲染;无参 = 当前组件,传参 = 指定组件列表 |
154
154
  | `dirty()` | `dirty(ids?: string[])` | 异步渲染(微任务批处理合并);`$` 内部就是调它 |
155
155
  | `selfId()` | `selfId(name: string)` | 注册组件自定义 ID,配合 `render(['id'])` 跨组件精准刷新 |
156
+ | `useChat()` | `useChat({ url, approveUrl?, body? })` | AI 对话会话:消息/流式/工具/审批,与 `$` 同容器(AiChat 配套) |
157
+ | `useAsync()` | `useAsync(fetcher)` | 异步取数:`data/loading/error` 响应式 + `reload()` |
158
+ | `useControlled()` | `useControlled({ value, onChange, name })` | 受控/非受控统一:受控判定 + 缺回调 warn + 内部状态跨渲染保持 |
159
+ | `useStableRef()` | `useStableRef(init, cleanup?)` | 稳定 ref 引用(根治内联 ref 陷阱) |
160
+ | `useDialog()` | `useDialog({ name })` | 全屏对话框:退场状态机 + 滚动锁 + 焦点 trap(Modal/Drawer 同款) |
161
+ | `useGlobalKey()` | `useGlobalKey(handler)` | 全局键盘监听(window keydown:mount 注册 + 卸载清理) |
162
+ | `useDrag()` | `useDrag({ onMove, onStart?, onEnd? })` | 指针拖拽(pointerdown 捕获 → window move delta / up 释放) |
163
+ | `useDragDrop()` | `useDragDrop({ onDrop, onDragOver?, onDragLeave? })` | 原生 DnD(drop/dragover/dragleave + preventDefault,dropProps spread) |
164
+ | `useReducedMotion()` | `useReducedMotion()` | 响应式系统偏好(JS 动画侧跳过;CSS 动画已有全局降级) |
165
+ | `useAnimationEnd()` | `useAnimationEnd(cb, { once? })` | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定) |
166
+ | `useTween()` | `useTween(target, { duration?, ease? })` | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset) |
167
+ | `usePresence()` | `usePresence({ name? })` | 通用显隐状态机(open→exit→closed,animationend 延迟卸载;useDialog 是其特例) |
156
168
  | `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
157
169
  | `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
158
170
  | `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
@@ -412,6 +424,87 @@ ctx.ui.render(['stats']) // 同步刷新
412
424
  - **同名冲突直接抛错**,每个自定义 ID 必须全局唯一
413
425
  - 配合 `selfId` 注册的组件在跨组件场景下无需把刷新逻辑层层传 props
414
426
 
427
+ #### `ctx.ui.useChat(options)` — AI 对话会话(AiChat 配套)
428
+
429
+ 会话语义的流式 AI 状态容器:消息累积 / 工具调用内嵌 / HITL 审批 / stop / retry,协议对页面完全透明(wf: 协议见 `design/ai-contract.md`)。返回的 handle 与 `ctx.ui.$()` **同一个 $**(页面状态与会话状态共处一容器):
430
+
431
+ ```tsx
432
+ // mount 阶段(服务端 `ai()` 中间件 + `AiChat` 组件配套)
433
+ const $ = ctx.ui.useChat({
434
+ url: '/api/chat', // POST 端点(返回 wf: SSE 流)
435
+ approveUrl: '/api/approve', // HITL 审批上行(缺省时 approve() 只清卡片)
436
+ body: (messages) => ({ messages, mode: 'agent' }), // 定制请求体
437
+ onEvent: (name, data) => { console.log('x:' + name, data) }, // x:* 透传
438
+ })
439
+
440
+ return (props) =>
441
+ h('div', {},
442
+ h(AiChat, { chat: $ }), // 标准对话界面:流式 token/工具卡/审批卡/自动滚动
443
+ $.streaming ? '生成中…' : '', // 会话状态与页面状态同容器
444
+ )
445
+ ```
446
+
447
+ **状态(`$` 上)**:
448
+
449
+ | 字段 | 类型 | 说明 |
450
+ |------|------|------|
451
+ | `$.messages` | `UiMessage[]` | 消息列表(`{ id, role, content, status, toolCalls?, approval?, usage?, error? }`) |
452
+ | `$.input` | `string` | 输入框值(双向绑定) |
453
+ | `$.streaming` | `boolean` | 是否正在流式生成 |
454
+ | `$.error` | `WfError \| null` | 最近错误(code + message) |
455
+ | `$.usage` | `WfUsage \| null` | token 用量(prompt/completion/total) |
456
+ | `$.step` | `WfStep \| null` | 最近 agent 步骤指示(思考/工具),done/error 时清空 |
457
+
458
+ **操作(`$` 上的方法)**:`$.send()`(发送当前输入)/ `$.stop()`(中止)/ `$.retry()`(截断到最后一条 user 重生成)/ `$.clear()`(清空)/ `$.approve(decision, note?)`(响应审批)/ `$.dispose()`(卸载时释放流)。
459
+
460
+ **共享 $ 的子组件**(如 `<AiChat chat={$}>`):父组件 dirty 不驱动子组件(三态 skip),子组件 mount 阶段 `chat.__watch?.(() => ctx.ui.dirty())` 自订阅(AiChat 已内置)。
461
+
462
+ #### `ctx.ui.useAsync(fetcher)` — 异步取数
463
+
464
+ `data/loading/error` 响应式 + `reload()` 重跑;数据就绪自动渲染当前组件。
465
+
466
+ ```tsx
467
+ const list = ctx.ui.useAsync(() => ctx.api.get<User[]>('/users'))
468
+
469
+ return () => list.loading ? h(Loading) : list.data?.map(u => h('div', {}, u.name))
470
+ ```
471
+
472
+ - `list.data` / `list.loading` / `list.error` 赋值自动 dirty 当前组件
473
+ - `list.reload()` 重跑;组件卸载后旧 Promise resolve 不再触发渲染(idRegistry 查无此组件,安全忽略)
474
+
475
+ #### 动画原语(4 层能力)
476
+
477
+ 动画能力按层组织(CSS 语言已有:`--wf-dur-*`/`--wf-ease-*`/`--wf-motion-*` Token + `--enter`/`--exit` 成对纪律):
478
+
479
+ | 原语 | 层 | 说明 |
480
+ |------|----|------|
481
+ | `useAnimationEnd(cb, { once? })` | 生命周期 | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定)——**组件内动画事件唯一入口** |
482
+ | `usePresence({ name? })` | 生命周期 | 显隐状态机:open → exit → closed(animationend 延迟卸载);`useDialog` 是其对话框特例 |
483
+ | `useTween(target, { duration?, ease? })` | 数值驱动 | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset + 每帧自动渲染) |
484
+ | `useReducedMotion()` | 偏好感知 | 响应式系统偏好——**JS 动画**(rAF/tween)侧跳过(CSS 动画已有全局降级) |
485
+ | `useInView` / `useScrollPosition` | 数值驱动 | 进入视口播 / 滚动位置联动(已有) |
486
+
487
+ ```tsx
488
+ // 入场 settle(面板坐标夹紧:动画期间矩形非稳态,结束后按稳态几何计算)
489
+ const settleRef = ctx.ui.useAnimationEnd(() => pos.refresh(), { once: true })
490
+ return () => h('div', { class: 'wf-panel', ref: settleRef }, ...)
491
+
492
+ // 退场(显隐状态机:open=false 播退场动画,animationend 后才真正卸载)
493
+ const { phase, ref, sync } = ctx.ui.usePresence()
494
+ const p = sync(props.open)
495
+ if (p === 'closed') return null
496
+ return h('div', { class: `wf-panel ${p === 'exit' ? '--exit' : '--enter'}`, ref }, ...)
497
+
498
+ // 数值动画(count-up:0 → 42,每帧自动渲染)
499
+ const n = ctx.ui.useTween(42, { duration: 400 })
500
+ return () => h('span', { class: 'wf-nums' }, String(n.value))
501
+
502
+ // 偏好感知(JS 动画侧跳过;CSS 动画 _base.css 已全局降级)
503
+ if (!ctx.ui.useReducedMotion()) { /* 启动 rAF/动画 */ }
504
+ ```
505
+
506
+ > 完整动画纪律见 [custom-components.md](custom-components.md) 的「8.5 动画」章节。
507
+
415
508
  #### CSS 层响应式(不碰 JS)
416
509
 
417
510
  配合 `weifuwu/layout` 的断点变体,纯 CSS 实现布局方向切换:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.64.0",
4
+ "version": "0.65.0",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {