Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) d25dfdbe1a 对话页换成组件库,桌面旁边可以打开本命盘(#106)。
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped
输入框里左边选模型、右边发送。回答下的赞、踩、复制、重新生成和追问改用组件库按钮。不满意原因和兑换窗改用对话框,句子不变。桌面顶栏可以打开当前人物的本命盘,能拖宽,刷新后还在;手机没有这个入口。当前会话和流式状态从 Home() 移进已有会话 store,useState 从 33 减到 25。流式、停止、排队、撤回、退点和寒暄免费没有改。

自测:tsc 退出码 0;lint 0 error、122 条警告;定向测试 275 通过、失败 0、取消 0;npm run build 退出码 0,/ 仍是 Static。首屏 gzip-9 639127,相对基线 634528 多 0.72%。隐私标记测试退出码 0。

Co-Authored-By: Grok (agent loop)
2026-10-25 03:15:13 +08:00
jesse-uxandGrok (agent loop) e4e240bf86 启程第一次出现时会动一下(#99)。
Independent Staging Quality Gate / validate (push) Failing after 9m8s
Independent Staging Quality Gate / publish (push) Skipped
建档、起步问题、清单打勾、天空过场和合盘按 T7 播放一次。减少动态效果时只剩淡入,没有星点和闪烁。Motion 仍按需加载,首屏 gzip 631199,相对 627046 约 +0.66%。

自测:tsc --noEmit、npm run lint(0 error)、定向测试 58 项 fail 0、npm run build(/ 仍 Static)。

Co-Authored-By: Grok (agent loop)
2026-10-25 02:17:32 +08:00
61 changed files with 2192 additions and 327 deletions
+16
View File
@@ -1,5 +1,21 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘
- 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。
- 回答下面的赞、踩、复制、重新生成仍是原来的四个动作。「不满意原因」和兑换窗仍是原来的文案。
- 桌面对话顶栏可以打开当前人物的本命盘,能拖宽,也能收起。刷新后还在。手机上看不到这个入口。
- 发消息、停止、排队、撤回、退点和寒暄免费没有改。追问仍是原来的依次浮出。
- Skill 版本不变(BUG-1434)。
## 2026-10-25 — 建档、清单和天空第一次出现时会动一下
- 建档的句子和卡片、起步问题、追问、档案行,第一次出现时轻轻上浮。按下按钮、问题卡和关系标签时会轻微缩小。
- 称呼和前两个起步问题会从原来的位置飞成气泡。清单每新完成一项,勾飞向右上角的进度环,环走一格。七项刚刚全满时,环上迸一次星点。
- 出生地确认后,天空从点击处展开。星星先亮再聚拢。天空淡出时,首页同时往上浮一点。第一次打开合盘,两个人的头像靠拢后再展开报告。
- 系统开了「减少动态效果」时,这些只剩淡入。星点和圆点闪烁去掉,没有东西飞来飞去。
- Skill 版本不变。
## 2026-10-24 — 侧栏可以拖宽,会话可以拖着排
- 桌面侧栏可以在 200 到 360 像素之间拖宽。拖到最左边会收成一条窄轨,再打开时回到上次的宽度。这个宽度记在这台浏览器里。
+16
View File
@@ -19430,3 +19430,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-1-shell-20261023`。
## BUG-1434 | 对话页换成组件库,旁边可以打开本命盘
- 状态:enhancement
- 首次发现:2026-10-25
- 最近更新:2026-10-25
- 影响面:首页对话输入框、模型选择、回答下的操作、追问、不满意原因、兑换窗、桌面对话旁边的本命盘。星盘页本身不在这次里。
- 用户现象:输入框里左边选模型,右边发送。价格只在模型菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,刷新后还在。手机上看不到这个入口。
- 触发条件:打开首页对话。本命盘按钮在视口已判定为桌面(不小于 768)之后出现。打开后面板才去取本命盘。
- 根因:不是缺陷。组件库迁移的对话页这一张(任务书 K2,决策 D4、D5、D7、D8、D9)。
- 修复:输入框、模型菜单、回答操作、追问、不满意原因、兑换窗和跳到最新改用已有的组件库零件。当前会话、流式和反馈状态从 `Home()` 移进已有的会话 store,`Home()` 的 useState 从 33 减到 25,useRef 仍是 37。本命盘放在可拖分栏里,宽 280–560,默认 360,开着和宽度记在本机;绘制复用星盘页的图,第一次打开才载入。手机不显示入口,也不把「开着」写成关掉。流式逐字、停止、排队、撤回、退点和寒暄免费没有改。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-2-chat-20261023.md`。没有浏览器登录态,不把发过一条对话或拖过本命盘写成已经在浏览器里做过。
- 防复发:`frontend/tests/chat-chart-panel-state.test.ts` 锁住本机记忆、宽度夹取、手机无入口,以及页面不把可拖分栏和星盘绘制打进同步包。对话回归锁住流式、停止、排队、撤回、退点和寒暄免费。增长合同锁住 `Home()` 的 hook 数不升。白名单测试锁住名单只减不增。
- 相关记录:issue #106。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K2。前置 #105 的外壳、#99 的启程动效。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-2-chat-20261023`。
@@ -0,0 +1,35 @@
# PROGRESS · 新用户启程四:动效(2026-10-25)
工单 #99。分支 `codex/onboarding-4-motion-20261022`。基线 `origin/staging` `352d45b2efebf3cd8278fe8ae6c9d4bb3074bf14`。只做任务书 **T7 动效(D6)**。T1–T6 和 #105 的侧栏不重做。K2 及之后的组件库换皮没做。
## 结论
建档的句子和卡片、起步问题、追问、档案行和清单行,第一次出现时会轻轻上浮。按下按钮、问题卡、选项和关系标签时会轻微缩小。称呼和前两个起步问题会飞成气泡。清单每新完成一项,勾从操作位置飞向右上角胶囊,进度环走一格,胶囊轻跳一下。七项刚刚全满时,进度环处迸一次星点。出生地点确认后,天空从点击处圆形展开;星星先亮再聚拢,行星逐个点亮并从东边连线,两行字先后浮出;天空淡出时首页同时上浮。第一次打开合盘,两个头像靠拢、亮一圈光,再展开关系画像。
系统开了「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。每段只在第一次出现时播放。除回答流式中的呼吸点外,没有无限循环。
## 做了什么
- 时长写在 `frontend/src/lib/motion/onboarding.ts`,曲线沿用已有的 `--ease-out` 和回弹。没有往全站预设表里加新键。
- 播放器在 `frontend/src/lib/motion/onboarding-run.ts`。Motion 仍走已有的 `loadMotionReact()`,页面出来之后才加载,不进首屏 HTML。
- 天空加强只在 `frontend/src/components/birth-sky/`。6.5 秒的时钟没改。3 秒内素材没到齐仍静默跳过。停留时的额外字段和导出封面的画布调用逐条相同。减少动态效果时不传入这些字段,也不做圆形展开和首页上浮。
- 进度环改用 `clip-path` 走格,不再用会触发布局的描边偏移。侧栏拖宽、会话排序、清单行的格子没有改。
- `Home()` 的 `useState` / `useRef` 没有增加。`scripts/jyotish_api_server.py` 没有加方法。没有改 `.gitea/**`,没有改生产部署,没有动数据库。白名单没有加长。
- 没有改既有断言。新测试是 `frontend/tests/onboarding-motion-20261022.test.ts`。
## 自测
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`:通过。
- `frontend` 下 `npm run lint`:0 error(121 条原有 warning,本单新文件没有新增)。
- 定向测试 58 项,fail 0,cancelled 0。含动效时长、减少动态效果没有位移、天空停留帧与封面逐调用相同、建档 / 清单 / 档案 / 增长合同 / 全站预设键。
- `npm run build`:`/` 仍是 Static。
- 首屏 gzip(`index.html` 引用的 36 个 `/_next/static/**.js`,Python gzip 等级 9,缺文件 0):基线 `352d45b2` 为 627046 字节,本分支为 631199 字节,增加 4153 字节,约 +0.66%,在 ±2% 以内(上限约 639587)。这 36 个文件里没有 motion。
- 没有跑全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。
## 环境缺口
本机没有登录态,没有用 Chrome 性能面板录完一整条新用户流程。不能把浏览器验收写成通过。真机条目在 `docs/testing/onboarding-journey-20261022.md` 的「动效」一节。
## 没有记新的 Bug
这一单是补动效,没有修一个已有现象。BUG-1420、1421、1422 已存在,没有再写。`docs/BUG_HISTORY.md` 没有追加。
@@ -0,0 +1,57 @@
# PROGRESS · 组件库 K2 对话页(2026-10-25)
工单 #106。分支 `codex/ui-kit-2-chat-20261023`。基线 `origin/staging` `e4e240bf863b49e75bf9c7e1ba54b40cb1265f64`(#105 新侧栏和 #99 启程动效已在这个提交里)。只做任务书 **K2 对话页**。K0、K1 已完成,K3 及之后没做。
## 结论
输入框里左边选模型,右边发送。各模型的价格只写在菜单里。回答下面仍是赞、踩、复制、重新生成。桌面上可以在对话旁边打开当前人物的本命盘,能拖宽,也能收起,刷新后还在。手机上看不到这个入口。发消息、停止、排队、撤回、退点和寒暄免费没有改。
## 做了什么
- 输入框改用组件库的组合输入。底边左边是模型菜单,只显示名字;「N 点」只在菜单项里。右边是圆形发送,和框的底边对齐。停止、排队召回和「内容由 AI 生成」仍在。模型菜单走外壳已经在用的延后载入:库还没到时,按钮上仍是当前模型名,点开还没有菜单。
- 赞、踩、复制、重新生成是图标按钮,各有提示。不满意原因和兑换窗改用对话框,句子不改。追问是描边小按钮,仍用启程进场依次浮出(260ms,间隔 64ms),没有另写时长。跳到最新和今日星语入口改用组件库按钮,原来的尺寸类还在。
- 当前会话、咨询阶段、停止中的标记、排队中的会话和请求、流式回答、赞踩和「已复制」从 `Home()` 移进已有的会话 store。消息列表本来就在会话列表里,跟着同一份 store。`Home()` 的 useState 从 33 减到 25。useRef 开工时是 37,做完仍是 37,没有新加。`page.tsx` 从 1419 行到 1450 行,粗护栏是 1567。`useSessionManagement` 仍在 `useRectificationSurface` 前面。
- 桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560,默认 360。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。收起不把宽度写成 0。手机视口还没定、或小于 768 时不显示按钮,也不因为藏起来就把「开着」写成关掉。
- 面板里是当前人物的本命盘,复用星盘页的绘制。只取本命分盘这一层。缓存里的盘必须是同一个人,否则重新取。没有骨架,也没有「正在加载」。不标星盘页那一次「画出来」的标记。可拖分栏和星盘绘制都在第一次打开后面才载入。外层对话网格没有改,里面用一列重复原来的行高,次级页面不包这一列。
- 流式逐字仍在 `chat-message-row.tsx`,这个文件没有改。消息进场仍是已有的 Motion。扣点和落库没有改。
- 白名单去掉 9 个已经不再违规的文件,没有加长,也没有改测试里写死的基线名单。
- 没有改 `.gitea/**`,没有改生产部署,没有动数据库,没有给 `scripts/jyotish_api_server.py` 加方法。没有重做 #99 的动效时长。
## 断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 输入框 | `<Textarea` 直接放在表单里,并带 `ref={inputRef}` | `<InputGroupTextarea`,仍绑定同一份草稿和同一个 ref | 输入框改用组合输入。聚焦和草稿订阅不变 |
| 跳到最新 | `<button` | `<Button`,仍是 `type="button"` | 改用组件库按钮。类名、焦点环和 44px 目标不变 |
| 今日星语 | `<button className="starter-entry"` | `<Button className="starter-entry"` | 改用组件库按钮。36px 胶囊和 44px 点按区仍由原来的类决定 |
| 兑换窗 | 自绘遮罩 `account-modal-overlay`,并用 `createPortal` | `Dialog`,内容类名仍是 `account-modal paywall-modal` | 改用组件库对话框。文案和这些类名留下 |
| 不满意原因 | 文件顶部静态引入,只在挂载容器里找文案 | 先建好测试文档再动态引入,容器和 `document.body` 都找 | 对话框在模块加载时才决定把内容挂到 body。测试文档必须先就位。提交、原因顺序和补充说明仍在 |
| 白名单 | 含首页和 8 个对话零件 | 这 9 个移出 | 它们已经改用组件库。名单只许变短 |
移出的 9 个文件:`page.tsx`、`chat-composer.tsx`、`chat-message-actions.tsx`、`conversation-follow-ups.tsx`、`jump-to-latest-button.tsx`、`model-selector.tsx`、`onboarding-redeem-paywall.tsx`、`reply-down-reason.tsx`、`starter-home.tsx`。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error |
| 定向测试 | 在 `frontend` 下 `node --import tsx --test` 下面列出的文件 | 275 条,通过 275,失败 0,取消 0 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 8.2s,TypeScript 17.3s。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `e4e240bf`:36 个文件,缺失 0,634528 字节。本单:36 个文件,缺失 0,639127 字节,多 4599(+0.72%)。±2% 上限 647219 |
定向测试文件:`account-dialog-inert-20260918.test.ts`、`ai-generated-notice-20261014.test.ts`、`character-remaining-contract.test.ts`、`chat-chart-panel-state.test.ts`、`chat-composer-queue.test.ts`、`chat-message-actions.test.ts`、`chat-navigation-a11y-contract.test.ts`、`chat-stream-layout.test.ts`、`composer-isolation-contract.test.ts`、`consult-first-frame-and-pacing-20260928.test.tsx`、`consultation-entrypoint.test.ts`、`consultation-recovery.test.ts`、`consultation-smalltalk-ui.test.tsx`、`consultation-smalltalk.test.ts`、`home-shell-growth-contract.test.ts`、`home-streaming-render-split.test.ts`、`home-warm-return-lifecycle.test.tsx`、`membership-page.test.ts`、`onboarding-checklist-20261022.test.ts`、`rectification-agentic-entry.test.ts`、`rectification-grounding-regenerate-20260927.test.ts`、`reply-quality-20260930.test.tsx`、`session-conversation-layout.test.ts`、`session-list-lifecycle.test.tsx`、`starter-entry-soften.test.ts`、`stream-text-response.test.ts`、`ui-kit-whitelist.test.ts`。
`Home()` hook:开工 useState 33、useRef 37、`page.tsx` 1419 行。做完 useState 25、useRef 37、1450 行。上限是 useState 36、useRef 37、1567 行。
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
## 偏离
- 测试用的假文档补了 `documentElement`、`dataset`、`style.setProperty`、`requestAnimationFrame` 和 `children`。组件库对话框要这些才能把内容挂出来。产品页面没有为测试改行为。
- 不满意原因的测试改成先建好文档再载入组件。同一份原因、补充说明和提交仍会发出去。
- 第一次构建把模型菜单静态打进首页脚本,gzip-9 是 655070(38 个文件,+20542,+3.24%,超出 ±2%)。模型菜单改走外壳已有的延后载入后再构建。延后载入后的首屏里,`dropdown-menu`、`chat-chart-split`、`chat-natal-chart`、`react-resizable-panels` 都是 0。`ResizablePanelGroup` 与 `VedicChartSvg` 的首屏命中数和基线一样(1 和 2);多出来的绘制和分栏只在未写进首页 HTML 的脚本里。库还没到时,模型按钮上仍是当前模型名。
## 环境缺口
- 没有登录态。没有在浏览器里发过一条对话,没有点过停止,没有拖过本命盘。本机记忆由测试读写 `chat_chart_panel_open` 和 `chat_chart_panel_width` 证明。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「对话」一节。
+1 -1
View File
@@ -40,7 +40,7 @@
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0 待验收(K1–K7 仍待领取) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022` | (一)已合入 staging;(二)待验收;(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
| —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) |
+19 -3
View File
@@ -1,6 +1,6 @@
# 新用户启程 · 真机清单(2026-10-22)
后面的 issue 往这份清单末尾追加。数据层(issue #95)、建档和起步三问(issue #96)、启程清单和一次性提示(issue #97)按下面的条目验收。动效还没做,不要在这一轮里验收它。
后面的 issue 往这份清单末尾追加。数据层(issue #95)、建档和起步三问(issue #96)、启程清单和一次性提示(issue #97)按下面的条目验收。动效(issue #99)按文末「动效」一节核对。
用新注册的邮箱。不要把真实出生资料写进记录。
@@ -63,6 +63,22 @@
6. 同一时刻只有一条。天空过场、兑换窗、任何弹窗或抽屉、回答还在生成时,提示不出现。这些过去之后再出现,不丢。
7. 点「知道了」、点页面别处或按 Esc,这一条消失。刷新后不再出现。提示出现时,输入框里的光标不被抢走。
## 以后追加
## 动效(issue #99)
- (四)动效,以及打开「减少动态效果」再走一遍
每一段只在第一次出现时动一下。来回切换、刷新进已看过的页面,不重播。回答还在一个字一个字出来时,句尾的呼吸点可以继续闪,这是唯一允许一直动的地方。
系统设置里打开「减少动态效果」后再走一遍:下面写了「淡入」的,只剩淡入;写了「去掉」的,完全不出现。没有东西从一处飞到另一处,也没有放大缩小。
1. 按下。建档里的「继续」「确定」、看谁的盘的两张卡、关系标签、三个起步问题、追问胶囊、清单里的「去做」和「先不看了」、提示上的「知道了」、档案里的一行,按下时轻微缩小,大约 120 毫秒,松开回到原样。
2. 进场。建档的每一句和每一张卡片、三个起步问题、「这一次不扣点」、追问胶囊、档案里的每一行和打开后的档案卡、清单里的每一行,第一次出现时从下面轻轻浮上来,大约 260 毫秒,一行比一行晚大约 60 毫秒。
3. 弹出。一次性提示、「这一次不扣点」、清单里新出现的勾,第一次出现时弹一下,大约 380 毫秒,带一点回弹。
4. 跟手。写下称呼并确认后,这句话从输入框飞成右边的气泡。点前两个起步问题,问题从那张卡片飞成气泡。大约 380 毫秒。第三个起步问题只让输入框亮一下,不飞。
5. 勾飞进进度环。清单每新完成一项,一个勾从你刚点的位置沿弧线飞向右上角胶囊,进度环多走一格,胶囊轻跳一下。大约 700 毫秒。打开页面时已经完成的项不飞。
6. 画出来。建档顶部四格进度,新完成的一格从左往右画出来。第一次打开星盘的北印度格、西洋盘的圆和分隔线、大运条,也是画出来,大约半秒到一秒。再打开不重画。
7. 天空。出生地点点「确定」之后,天空从点击的位置像一个圆展开,大约 680 毫秒。星星先亮起来再聚到一起;行星一颗一颗亮,并从东边连一条线,线在星星聚拢前收回。两行字先后浮出。整段仍是大约 6.5 秒。素材 3 秒内没到齐,天空不出现,直接进下一步,没有转圈。天空淡出时稍微放大,底下的首页同时往上浮一点。首页上的字和按钮不因为这场过场改掉。
8. 合盘。第一次打开一份关系画像,两个人的头像从两侧靠拢,中间亮起一圈光,然后报告展开。大约 1.2 秒。再打开同一份不重演。
9. 庆祝。这一次使用里七项刚刚全满时,进度环处迸一次星点,大约 1 秒,只这一次。进站时已经全满的账户没有星点,胶囊也不出现。
10. 小圆点。侧栏「星盘」和星盘页还没看过的页签,圆点第一次出现时闪两下,然后停住。不是一直闪。
11. 减少动态效果。打开系统的「减少动态效果」后再走 1–10:星点和圆点闪烁去掉;天空不展开、不聚星、不连线,首页不上浮;勾不飞;头像不靠拢;其余只是淡入。按下只是轻轻变淡,不缩小。
环境缺口:本机没有登录态,也没有用 Chrome 性能面板录完一整条新用户流程。上面 1–11 需要产品负责人在真机上看。自动化只证明了时长,以及「减少动态效果」下的规格里没有位移、缩放和擦除。
@@ -21,3 +21,11 @@
2. 顶栏点数是硬币图标加数字。读屏能听到「点」,眼睛看不到这个字。
3. 设置里仍是个人资料、账户与点数、通用设置。退出登录要再确认一次。反馈与投诉仍能打开。
4. 建完档的账户,右上角启程清单还在,点数按钮的左边。
## 对话
1. 输入框里,左边是模型名字,右边是圆形发送。点开模型,每一项旁边才有「N 点」。顶栏不再放模型选择。
2. 发出一条消息,可以停止。停止后该退的点仍退。寒暄不扣点。回答被撤下时仍不扣点。
3. 回答下面可以赞、踩、复制。点踩之后可以选原因,也可以什么都不选就提交。兑换窗的句子和以前一样。
4. 桌面上,点数右边有一个打开本命盘的按钮。打开后是当前人物的本命盘。拖右缘,宽度停在大约 280 到 560 像素。收起后再打开,宽度还在。刷新页面,开着和宽度都还在。
5. 把窗口改成大约 375 像素宽。顶栏没有打开本命盘的按钮。
+28 -6
View File
@@ -4,15 +4,35 @@
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
## 启程清单与一次性提示(2026-10-24)
## 对话页(2026-10-25)
输入框仍是一个框。框底左边是模型菜单,只显示模型名;价格「N 点」只在菜单里。菜单用外壳已有的延后载入,不进首屏;库还没到时按钮上仍是模型名。右边是圆形发送,和框的底边对齐。回答下面赞、踩、复制、重新生成是 26px 的图标按钮,各有一句提示。不满意原因和兑换窗用组件库对话框,句子不改。追问是描边小按钮,仍按启程进场依次浮出(260ms,间隔 64ms),不另写一套时长。
桌面顶栏、点数右边,是打开或收起本命盘的图标按钮,提示「打开本命盘」或「收起本命盘」。面板在可拖分栏里,宽 280–560px,默认 360。拖到最窄可以收起。开着和宽度记在本机 `chat_chart_panel_open`、`chat_chart_panel_width`。手机(视口还没定,或小于 768)不显示这个按钮,也不因为藏起来就把「开着」写成关掉。面板里是当前人物的本命盘,复用星盘页的绘制,不写骨架、不写「正在加载」。可拖分栏和星盘绘制都在第一次打开后面才载入,不进首屏。
## 新用户启程(2026-10-25)
首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。
七项在这一次使用里刚满时,抽屉或弹出层再打开一次:标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」关掉后胶囊消失。不另做星点。进站时已经满的账户,胶囊和这一屏都不出现。
七项在这一次使用里刚满时,抽屉或弹出层再打开一次,进度环处同时迸一次星点(1000ms,只一次)。标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」关掉后胶囊消失。进站时已经满的账户,胶囊、这一屏和星点都不出现。
一次性提示用现有弹出层,宽 `min(18rem, calc(100vw - 2rem))`,不抢焦点。侧栏两项从锚点右侧伸出,其余从上方伸出,间距 8px。按钮是「知道了」。同一时刻最多一条。天空过场、兑换窗、任何弹窗或抽屉、启程清单正打开、回答还在生成时,提示不出;挡着的那一条等条件过去再出。侧栏「星盘」、手机菜单和星盘四个页签上的小圆点是 8px,贴在右上角 4px,不是字符。
不新增动效时长。弹出层和抽屉仍走现有的 120ms / 180ms。
启程专用动效只在元素第一次出现时播放。重绘、来回切换不重播。除回答流式中的呼吸点外,没有无限循环。只动 `opacity`、`transform`、颜色、`clip-path`,不触发布局。Motion 用到才加载,不进首屏。系统开启「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。
| 类别 | 用在哪 | 时长 | 曲线 |
| --- | --- | --- | --- |
| 按下反馈 | 按钮、问题卡、选项、关系标签被按下时轻微缩小 | 120ms | `--ease-out` |
| 进场 | 建档消息与卡片、起步问题、追问胶囊、档案卡、目录行;依次上浮,间隔 64ms(落在 50–80ms) | 260ms | `--ease-out` |
| 弹出 | 一次性提示、「这一次不扣点」、勾 | 380ms | 回弹 `cubic-bezier(.34, 1.56, .64, 1)` |
| 跟手 | 称呼从输入框飞成气泡;起步问题飞成气泡 | 380ms | `--ease-out` |
| 勾飞进进度环 | 清单每完成一项:从操作位置沿弧线飞向胶囊,进度环走一格,胶囊轻跳一下 | 700ms | 弧线 |
| 画出来 | 建档进度条;星盘格线、西洋盘圆与分隔线、大运条 | 640–860ms(落在 520–900ms) | `--ease-out` |
| 场景转换 | 出生地→天空:从点击处圆形展开;天空→首页:淡出放大,首页同时上浮 | 680ms(落在 650–700ms) | `--ease-out` |
| 合盘 | 两个头像从两侧靠拢,泛起一圈光,再展开关系画像 | 1.2s | `--ease-out` |
| 庆祝 | 七项全满时,进度环处迸一次星点 | 1000ms,只一次 | `--ease-out` |
侧栏「星盘」和星盘页签上的小圆点,第一次出现时闪两下(800ms,有限次,不是循环)。减少动态效果时不闪。弹出层和抽屉本身仍走组件库已有的进出,不另加一套时长。
## 婚恋合盘报告(2026-10-19)
@@ -919,6 +939,8 @@ Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motio
K0 stores the later site-motion rows as data in `src/lib/motion/presets.ts` (duration, curve, direction) and wraps `LazyMotion` plus `MotionConfig reducedMotion="user"` in `src/lib/motion/provider.tsx`. No page mounts that provider yet, and the presets are not wired. Sidebar and other spatial changes stay instant. The one live entrance is still the message row at 160ms.
启程专用的时长和回弹曲线写在文首「新用户启程」,不改上面这张表。
## 7. Depth & Surface
Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals.
@@ -1358,10 +1380,10 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
- **触发:** 首页建档里出生地保存成功(`saveOnboardingPlace` 成功、下一步已设好之后)。本人路径取本人的天空;帮别人看取这一位的天空。没有「看过」标记、不写库。星盘档案里另外新建家人、设置里改资料、平时打开首页都不触发。
- **不等待:** 保存成功后建档照常走开,同时取 `/api/birth-sky?subject=` 当前这一位、等宋体(最多 1.5 秒)、加载过场代码;三样都在 **3 秒**内到齐才开始放,否则静默跳过:不提示、不重试、不转圈、不骨架(§9)。过场开始时画面数据已就绪。帮别人看时,若上升星座已经在今日星语那次计算里拿到,画面上多一行「那一刻,{名字}的上升在{星座}」。名字或星座还没有时不写这行,原来的天空句子照常,不等第二次排盘。
- **时序(约 6.5 秒,第三轮放慢):** ① 汇聚约 3 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 400ms 淡入,避免页面一下变黑。② 显现文字约 2.5 秒:顶部小字、细线、主句、事实句、caption、底部说明在前 600ms 淡入,然后停留。③ 淡出约 1 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页),下一步不因它改变。
- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数;停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。
- **时序(约 6.5 秒,第三轮放慢):** ① 汇聚约 3 秒:前 420ms 星星先亮起,位置仍停在散开处,然后 ease-out(三次方)汇聚到真实投影位置。行星在这 3 秒里逐个点亮,并从东边地平线(上升)连一条线,线在汇聚结束前收回。散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 400ms 淡入,避免页面一下变黑。出生地点下「确定」的位置,用 680ms 的圆形从点击处展开到全屏。② 显现文字约 2.5 秒:主句先浮出,事实句晚 120ms 再浮出;顶部小字、细线、caption、底部说明仍在前 600ms 淡入,然后停留。③ 淡出约 1 秒:整层透明度降到 0,同时放大到 1.06。底下的首页内容不变,并在同一段里上浮一次。
- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数。加强用的额外字段在停留时是「星星已亮、行星已亮、连线为 0、两行字都已出现、汇聚为 1」;这些值和省略它们画出的调用序列相同。减少动态效果时不传入这些字段。停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。
- **全屏、无按钮:** 原生 `<dialog>`,`createPortal` 挂到 `document.body`(在 `SidebarInset` 之外,不受弹窗打开时的 `inert` 影响,与账户弹窗、兑换弹窗同一做法,BUG-968 / BUG-1080)铺满视口,底色是 `night` 的页底色;画布按视口取最大的 3:4 区域、乘 `devicePixelRatio` 绘制,`requestAnimationFrame` 驱动,卸载时取消。点击、触摸(`pointerdown`)或 Esc 立刻结束,直接进入下一步。
- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚:成品画面整体淡入(600ms)、停留、同一时刻淡出(1 秒),总长同样约 6.5 秒。
- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚、不先亮星、不连线、不逐行浮出、不从点击处圆形展开、首页不上浮:成品画面整体淡入(600ms)、停留、同一时刻淡出(1 秒),总长同样约 6.5 秒。
- **与兑换弹窗不再同时出现:** 0 点数账户的「解锁完整咨询」兑换弹窗不再在首页出现时自动弹出,改为本页第一次因点数不足发不出消息时弹(见 §账户弹窗「Placement」条),所以建盘后首页只有过场。
- **结束后没有提示:** 过场正常结束或被点掉后直接露出下一步,不弹任何提示(2026-09-29 产品去掉原来的「这片天空在星盘页可以保存。」)。
- **平时的首页加载动画不改。** 这是用户操作之后的一次性结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;轨道环揭幕逻辑不动。
@@ -1,5 +1,4 @@
[
"src/app/(app)/page.tsx",
"src/app/error.tsx",
"src/app/global-error.tsx",
"src/components/ayanamsa-preference-field.tsx",
@@ -19,20 +18,14 @@
"src/components/chart-page/chart-type-picker.tsx",
"src/components/chart-page/chart-vedic-tab.tsx",
"src/components/chart-profile-form.tsx",
"src/components/chat-composer.tsx",
"src/components/chat-message-actions.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/conversation-follow-ups.tsx",
"src/components/email-otp-login.tsx",
"src/components/ephemeris/ephemeris-page.tsx",
"src/components/home-bootstrap-error.tsx",
"src/components/home-onboarding-shell.tsx",
"src/components/jump-to-latest-button.tsx",
"src/components/legal/legal-consent-gate.tsx",
"src/components/legal/login-consent.tsx",
"src/components/markdown-code-block.tsx",
"src/components/model-selector.tsx",
"src/components/onboarding-redeem-paywall.tsx",
"src/components/people/people-page.tsx",
"src/components/person-relation-field.tsx",
"src/components/personal-report/personal-report-center.tsx",
@@ -48,8 +41,6 @@
"src/components/rectification-composer-reopen.tsx",
"src/components/rectification-range-delivery.tsx",
"src/components/rectification-segment-delivery.tsx",
"src/components/reply-down-reason.tsx",
"src/components/starter-home.tsx",
"src/components/synastry/synastry-add-dialog.tsx",
"src/components/synastry/synastry-picker.tsx",
"src/components/synastry/synastry-report-view.tsx"
+2
View File
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { AccountDeletionGate } from "@/components/account-deletion-gate";
import { AppSidebar } from "@/components/app-sidebar";
import { OnboardingHintLayer } from "@/components/onboarding-hint-layer";
import { OnboardingMotionHost } from "@/components/onboarding-motion-host";
import { LegalConsentGate } from "@/components/legal/legal-consent-gate";
import { PageSwitch } from "@/components/shell-motion";
import { ShellFrame } from "@/components/shell-frame";
@@ -46,6 +47,7 @@ function AppShell({ children }: { children: ReactNode }) {
<LegalConsentGate signedIn={Boolean(account) && !list.signedOut} />
</main>
<OnboardingHintLayer />
<OnboardingMotionHost />
</OnboardingJourneyProvider>
<AccountDeletionGate signedIn={Boolean(account)} />
</SidebarProvider>
+47 -16
View File
@@ -29,12 +29,10 @@ import {
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
import { ChatProfilePicker } from "@/components/chat-profile-picker";
import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
@@ -47,6 +45,8 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/react/shallow";
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
import { personRelationLabel } from "@/lib/person-relation";
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
@@ -146,7 +146,6 @@ import {
type Profile,
type ReplyOutcome,
type RequestError,
type StreamingReply,
type Theme,
} from "@/lib/home-types";
import { chatSubjectSendBlocked } from "@/lib/chat-profile-picker-model";
@@ -225,17 +224,48 @@ export default function Home() {
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>(() => warmStart()?.daily ?? { kind: "pending" });
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => warmStart()?.modelCatalog ?? null);
const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? "");
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(() => {
const warm = warmStart();
useSessionStore.getState().mountHomeConversation(warm?.landing.activeSessionId ?? "");
return warm?.modelCatalog ?? null;
});
const {
activeSessionId,
setActiveSessionId,
consultationPhase,
setConsultationPhase,
cancellationPending,
setCancellationPending,
pendingSessionId,
setPendingSessionId,
pendingRequestId,
setPendingRequestId,
streamingReply,
setStreamingReply,
messageFeedback,
setMessageFeedback,
copiedMessageKey,
setCopiedMessageKey,
} = useSessionStore(useShallow((state) => ({
activeSessionId: state.activeSessionId,
setActiveSessionId: state.setActiveSessionId,
consultationPhase: state.consultationPhase,
setConsultationPhase: state.setConsultationPhase,
cancellationPending: state.cancellationPending,
setCancellationPending: state.setCancellationPending,
pendingSessionId: state.pendingSessionId,
setPendingSessionId: state.setPendingSessionId,
pendingRequestId: state.pendingRequestId,
setPendingRequestId: state.setPendingRequestId,
streamingReply: state.streamingReply,
setStreamingReply: state.setStreamingReply,
messageFeedback: state.messageFeedback,
setMessageFeedback: state.setMessageFeedback,
copiedMessageKey: state.copiedMessageKey,
setCopiedMessageKey: state.setCopiedMessageKey,
})));
const draftTheme = useRef<Theme | null>(null);
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
@@ -1190,7 +1220,7 @@ export default function Home() {
};
return (
<>
<ChatChartFrame>
<DailyStarlanguageBinder
accountId={accountId}
bootstrapPhase={bootstrapPhase}
@@ -1256,6 +1286,7 @@ export default function Home() {
label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}
onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })}
/>
<ChatChartToggle />
</div>
</header>
@@ -1414,6 +1445,6 @@ export default function Home() {
}}
/>
)}
</>
</ChatChartFrame>
);
}
+21
View File
@@ -1022,6 +1022,15 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
it back. `clip` creates no scroll container, so it cannot happen again. */
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
/* Home spans the outer rows with one frame, then repeats the same templates
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; }
.chat-chart-split { height: 100%; min-width: 0; min-height: 0; }
.chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; }
.chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); }
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-natal-chart { height: 100%; min-width: 0; overflow: auto; padding: var(--space-3); }
.chat-header-actions .chat-chart-toggle { width: 32px; height: 32px; }
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
.chat-header > div { min-width: 0; }
/* One line, 46px tall. The chart chip stays beside the title and ellipsizes
@@ -1138,6 +1147,15 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
overflow-wrap: anywhere;
}
.onboarding-hint p { margin: 0; }
.onboarding-progress-ring {
position: relative;
width: 18px;
height: 18px;
display: inline-block;
flex: none;
}
.onboarding-progress-ring svg { width: 18px; height: 18px; display: block; }
.onboarding-progress-ring-value { position: absolute; inset: 0; }
/* The checklist trigger stays 32px. The credit control is a normal outline
button (44px) so the number is readable; 「点」 lives only in its name. */
.chat-header-actions button.credit-balance {
@@ -2334,6 +2352,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-birth-line { width: 100%; min-height: 44px; justify-content: space-between; }
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) .chat-column { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
+1 -1
View File
@@ -362,7 +362,7 @@ export function AppSidebar({
{active ? <SidebarCurrentHighlight /> : null}
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
{href === "/chart" && showChartDot ? <OnboardingDot /> : null}
{href === "/chart" && showChartDot ? <OnboardingDot playKey="sidebar-chart" /> : null}
</SidebarMenuLink>
</SidebarMenuItem>
);
@@ -6,6 +6,8 @@ import { createPortal } from "react-dom";
import "./birth-sky-converge.css";
import { birthSkyEntranceFrame, birthSkySceneStyle } from "./birth-sky-entrance";
import { readSkyOrigin } from "./sky-origin";
import { birthSkyHoldAtMs, birthSkyRevealAt, birthSkyScatter, type BirthSkyRevealMode } from "@/lib/birth-sky/converge";
import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/birth-sky/draw";
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
@@ -66,17 +68,30 @@ export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }:
let frameHandle = 0;
let started: number | null = null;
let finished = false;
let toldHome = false;
let elapsedMs = 0;
let last: ReturnType<typeof birthSkyRevealAt> | null = null;
const draw = (moment: ReturnType<typeof birthSkyRevealAt>) => {
last = moment;
const extras = birthSkyEntranceFrame(elapsedMs, reduced);
const scene = birthSkySceneStyle(elapsedMs, reduced, mode, readSkyOrigin(), {
w: window.innerWidth,
h: window.innerHeight,
});
layer.style.opacity = String(moment.layer);
layer.style.clipPath = scene.clipPath;
layer.style.transform = scene.transform;
if (scene.notifyHome && !toldHome) {
toldHome = true;
window.dispatchEvent(new Event("onboarding-sky-home"));
}
drawBirthSky(context as BirthSkyCanvas, reveal.sky, {
palette: "night",
width: surface.width,
height: surface.height,
serif: reveal.serif,
}, { ...moment.frame, scatter });
}, extras ? { ...moment.frame, scatter, ...extras } : { ...moment.frame, scatter });
};
const fit = () => {
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
@@ -97,7 +112,8 @@ export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }:
const tick = (now: number) => {
if (finished) return;
started ??= now;
const moment = birthSkyRevealAt(now - started, reduced, mode);
elapsedMs = now - started;
const moment = birthSkyRevealAt(elapsedMs, reduced, mode);
draw(moment);
if (moment.done) finish();
else if (!moment.held) frameHandle = requestAnimationFrame(tick);
@@ -0,0 +1,69 @@
/**
* Extra beats inside the existing 6.5s sky clock.
* Reduced motion returns null: the painter keeps the settled fade.
*/
import { BIRTH_SKY_CONVERGE_MS, easeOutCubic } from "@/lib/birth-sky/converge";
export const BIRTH_SKY_SCENE_OPEN_MS = 680;
export const BIRTH_SKY_SCENE_CLOSE_MS = 680;
const STAR_LIGHT_MS = 420;
export type BirthSkyEntrance = {
starLight: number;
convergeOverride: number;
planetLight: number;
ascendantLinks: number;
textLines: readonly [number, number];
};
function clamp01(value: number): number {
return value <= 0 ? 0 : value >= 1 ? 1 : value;
}
export function birthSkyEntranceFrame(elapsedMs: number, reduced: boolean): BirthSkyEntrance | null {
if (reduced) return null;
const t = Math.max(0, elapsedMs);
if (t >= BIRTH_SKY_CONVERGE_MS + 640) {
return { starLight: 1, convergeOverride: 1, planetLight: 1, ascendantLinks: 0, textLines: [1, 1] };
}
const starLight = clamp01(t / STAR_LIGHT_MS);
const convergeOverride = t <= STAR_LIGHT_MS
? 0
: easeOutCubic((t - STAR_LIGHT_MS) / (BIRTH_SKY_CONVERGE_MS - STAR_LIGHT_MS));
const planetLight = clamp01((t - 700) / 1_900);
const linksIn = clamp01((t - 900) / 1_200);
const linksOut = t >= 2_600 ? 1 - clamp01((t - 2_600) / 400) : 1;
const ascendantLinks = t >= BIRTH_SKY_CONVERGE_MS ? 0 : linksIn * linksOut;
const textT = Math.max(0, t - BIRTH_SKY_CONVERGE_MS);
return {
starLight,
convergeOverride,
planetLight,
ascendantLinks,
textLines: [clamp01(textT / 420), clamp01((textT - 120) / 420)],
};
}
export function birthSkySceneStyle(
elapsedMs: number,
reduced: boolean,
mode: "reveal" | "hold",
origin: { x: number; y: number } | null,
viewport: { w: number; h: number },
): { clipPath: string; transform: string; notifyHome: boolean } {
if (reduced || mode !== "reveal") return { clipPath: "", transform: "", notifyHome: false };
const open = Math.min(1, Math.max(0, elapsedMs) / BIRTH_SKY_SCENE_OPEN_MS);
const x = origin?.x ?? viewport.w / 2;
const y = origin?.y ?? viewport.h / 2;
const clipPath = open >= 1 ? "" : `circle(${(open * 150).toFixed(2)}% at ${x}px ${y}px)`;
const fadeStart = 5_500;
if (elapsedMs < fadeStart) return { clipPath, transform: "", notifyHome: false };
const progress = Math.min(1, (elapsedMs - fadeStart) / BIRTH_SKY_SCENE_CLOSE_MS);
return {
clipPath,
transform: `scale(${(1 + progress * 0.06).toFixed(4)})`,
notifyHome: true,
};
}
@@ -0,0 +1,12 @@
/** Click point for the birth-place → sky circle. No animation here. */
let origin: { x: number; y: number } | null = null;
export function rememberSkyOrigin(x: number, y: number): void {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
origin = { x, y };
}
export function readSkyOrigin(): { x: number; y: number } | null {
return origin;
}
@@ -72,7 +72,7 @@ function Timeline({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: st
return (
<div className="chart-page-dasha-axis">
<div className="chart-page-dasha-bar-wrap">
<div className="chart-page-dasha-bar" role="img" aria-label={`${formatYearMonth(start)} 到 ${formatYearMonth(end)}`}>
<div className="chart-page-dasha-bar" role="img" data-onboarding-draw="dasha" data-onboarding-draw-key={start} aria-label={`${formatYearMonth(start)} 到 ${formatYearMonth(end)}`}>
{visible.map((period) => {
const weight = span > 0 ? (dayMs(period.end) - dayMs(period.start)) / span : 1 / visible.length;
return (
@@ -202,7 +202,7 @@ export function ChartPageView({
onClick={() => selectTab(item.id)}
>
{item.label}
{journey?.tabHasDot(item.id) ? <OnboardingDot /> : null}
{journey?.tabHasDot(item.id) ? <OnboardingDot playKey={`tab-${item.id}`} /> : null}
</button>
))}
</div>
@@ -338,7 +338,7 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<ChartSymbolHelp resetKey={JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, ariaLabel, ascendantHouse, chart])}>
<div className="chart-page-vedic-plot">
<div className="chart-page-vedic-plot" data-onboarding-draw="vedic">
<VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} interactive />
</div>
</ChartSymbolHelp>
@@ -80,7 +80,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini
return (
<div className="chart-page-western">
<div className="chart-page-western-layout">
<div className="chart-page-western-wheel">
<div className="chart-page-western-wheel" data-onboarding-draw="western">
<ChartSymbolHelp resetKey="western">
{(explain) => (
<WesternWheelSvg
@@ -0,0 +1,91 @@
"use client";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useCurrentSubject } from "@/lib/current-subject";
import { useUiStore } from "@/stores/ui-store";
type SplitComponent = (props: {
open: boolean;
width: number;
subjectId: string;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
children: ReactNode;
}) => ReactNode;
export function ChatChartToggle() {
const { isMobile, ready } = useSidebar();
const open = useUiStore((state) => state.chatChartOpen);
const setOpen = useUiStore((state) => state.setChatChartOpen);
if (!ready || isMobile) return null;
const label = open ? "收起本命盘" : "打开本命盘";
const Icon = open ? PanelRightClose : PanelRightOpen;
return (
<Tooltip>
<TooltipTrigger
render={(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="chat-chart-toggle"
aria-label={label}
aria-pressed={open}
onClick={() => setOpen(!open)}
>
<Icon aria-hidden="true" />
</Button>
)}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
/**
* One cell spanning every row of `.chat-panel`. The inner `.chat-column`
* repeats today's row template so secondary pages can keep the outer grid.
* The split (and the chart drawing) load the first time the panel opens.
*/
export function ChatChartFrame({ children }: { children: ReactNode }) {
const { isMobile, ready } = useSidebar();
const subjectId = useCurrentSubject();
const open = useUiStore((state) => state.chatChartOpen);
const width = useUiStore((state) => state.chatChartWidth);
const hydrated = useUiStore((state) => state.chatChartHydrated);
const hydrate = useUiStore((state) => state.hydrateChatChart);
const setOpen = useUiStore((state) => state.setChatChartOpen);
const setWidth = useUiStore((state) => state.setChatChartWidth);
const [Split, setSplit] = useState<SplitComponent | null>(null);
const desktop = ready && !isMobile;
useEffect(() => {
hydrate();
}, [hydrate]);
useEffect(() => {
if (!desktop || !hydrated || !open || Split) return;
let cancelled = false;
void import("@/components/chat-chart-split").then((mod) => {
if (!cancelled) setSplit(() => mod.ChatChartSplit);
});
return () => { cancelled = true; };
}, [Split, desktop, hydrated, open]);
const column = <div className="chat-column">{children}</div>;
if (!Split || !desktop) {
return <div className="chat-chart-frame">{column}</div>;
}
return (
<div className="chat-chart-frame">
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth}>
{column}
</Split>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ChatNatalChart } from "@/components/chat-natal-chart";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-panel-state";
export function ChatChartSplit({
open,
width,
subjectId,
onOpenChange,
onWidth,
children,
}: {
open: boolean;
width: number;
subjectId: string;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
children: ReactNode;
}) {
const panelRef = useRef<PanelImperativeHandle | null>(null);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (open && panel.isCollapsed()) panel.expand();
if (!open && !panel.isCollapsed()) panel.collapse();
}, [open]);
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
if (!meta.isUserInteraction) return;
const panel = panelRef.current;
if (!panel) return;
if (panel.isCollapsed()) {
onOpenChange(false);
return;
}
onOpenChange(true);
const pixels = panel.getSize().inPixels;
if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels));
}
return (
<ResizablePanelGroup orientation="horizontal" className="chat-chart-split" onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="chat-column" defaultSize="100%">
{children}
</ResizablePanel>
<ResizableHandle withHandle className="chat-chart-resize-handle" />
<ResizablePanel
id="chat-natal"
panelRef={panelRef}
collapsible
collapsedSize={0}
minSize={CHAT_CHART_WIDTH_MIN}
maxSize={CHAT_CHART_WIDTH_MAX}
defaultSize={open ? width : 0}
groupResizeBehavior="preserve-pixel-size"
>
<ChatNatalChart subjectId={subjectId} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
+46 -41
View File
@@ -4,7 +4,11 @@ import { useSyncExternalStore } from "react";
import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react";
import { ArrowUp, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
InputGroup,
InputGroupAddon,
InputGroupTextarea,
} from "@/components/ui/input-group";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
@@ -47,8 +51,7 @@ type ChatComposerProps = {
readonly queued?: { text: string; onRecall: () => void };
/**
* Controls that belong to the composer itself (model picker, chart subject).
* They render on the composer's own bottom row; there is no longer a strip
* below the composer holding a permanent 44px of chrome.
* They sit on the left of the field's bottom row. The send button is on the right.
*/
readonly toolbar?: ReactNode;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
@@ -91,48 +94,50 @@ export function ChatComposer({
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
<p className="composer-queue__text">{queued.text}</p>
</div>
<button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
<Button className="composer-queue__recall" type="button" variant="ghost" onClick={queued.onRecall}>
{COMPOSER_QUEUE_RECALL_LABEL}
</button>
</Button>
</div>
) : null}
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<div className="composer-row">
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
<div className="composer-row-end">
{showRemaining ? (
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
) : null}
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</div>
</div>
<InputGroup className="border-0 bg-transparent shadow-none ring-0">
<InputGroupTextarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<InputGroupAddon align="block-end" className="composer-row">
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
<div className="composer-row-end">
{showRemaining ? (
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
) : null}
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</div>
</InputGroupAddon>
</InputGroup>
</form>
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
</>
@@ -1,7 +1,11 @@
"use client";
import type { ReactElement } from "react";
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export type ChatMessageFeedback = "up" | "down";
export function toggleChatMessageFeedback(
@@ -11,6 +15,15 @@ export function toggleChatMessageFeedback(
return current === requested ? undefined : requested;
}
function ActionTip({ label, button }: { label: string; button: ReactElement }) {
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function ChatMessageActions({
feedback,
copied = false,
@@ -29,39 +42,62 @@ export function ChatMessageActions({
}>) {
return (
<div className="message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback === "up"}
className={feedback === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => onFeedback("up")}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback === "down"}
className={feedback === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => onFeedback("down")}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={onCopy}>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</button>
<ActionTip
label="赞"
button={(
<Button
aria-label="赞"
aria-pressed={feedback === "up"}
className={feedback === "up" ? "is-active" : ""}
size="icon-sm"
type="button"
variant="ghost"
onClick={() => onFeedback("up")}
>
<ThumbsUp aria-hidden="true" />
</Button>
)}
/>
<ActionTip
label="踩"
button={(
<Button
aria-label="踩"
aria-pressed={feedback === "down"}
className={feedback === "down" ? "is-active" : ""}
size="icon-sm"
type="button"
variant="ghost"
onClick={() => onFeedback("down")}
>
<ThumbsDown aria-hidden="true" />
</Button>
)}
/>
<ActionTip
label={copied ? "已复制" : "复制"}
button={(
<Button aria-label="复制回答" size="icon-sm" type="button" variant="ghost" onClick={onCopy}>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</Button>
)}
/>
{onRegenerate && (
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
type="button"
onClick={onRegenerate}
>
<RotateCcw aria-hidden="true" />
</button>
<ActionTip
label={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
button={(
<Button
aria-label="重新生成回答"
disabled={!canRegenerate}
size="icon-sm"
type="button"
variant="ghost"
onClick={onRegenerate}
>
<RotateCcw aria-hidden="true" />
</Button>
)}
/>
)}
</div>
);
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { fetchChartView } from "@/lib/chart-view-client";
import type { ChartViewResponse, NorthIndianChart } from "@/lib/chart-view-contract";
import { peekChartPage } from "@/lib/secondary-page-data";
type ChartDrawing = typeof import("@/components/personal-report/vedic-chart-svg").VedicChartSvg;
function natalChart(view: ChartViewResponse | null, subjectId: string): NorthIndianChart | null {
if (!view || view.status !== "ok" || view.subjectId !== subjectId) return null;
const varga = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0];
return varga?.chart ?? null;
}
/**
* Current person's D1, drawn with the chart page's SVG. Loaded only after the
* panel opens. No skeleton and no "正在加载": the region stays quiet until the
* drawing is ready. Do not mark `data-onboarding-draw` here; that key belongs
* to the chart page's first draw.
*/
export function ChatNatalChart({ subjectId }: { subjectId: string }) {
const peeked = peekChartPage();
const cached = peeked?.kind === "view" ? natalChart(peeked.view, subjectId) : null;
const [remote, setRemote] = useState<{ subjectId: string; chart: NorthIndianChart | null } | null>(null);
const [Drawing, setDrawing] = useState<ChartDrawing | null>(null);
const chart = remote?.subjectId === subjectId ? remote.chart ?? cached : cached;
useEffect(() => {
let cancelled = false;
void import("@/components/personal-report/vedic-chart-svg").then((mod) => {
if (!cancelled) setDrawing(() => mod.VedicChartSvg);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (cached) return;
const controller = new AbortController();
void fetchChartView({ layers: ["varga"], signal: controller.signal, subjectId })
.then((result) => {
if (controller.signal.aborted) return;
setRemote({ subjectId, chart: natalChart(result.body, subjectId) });
})
.catch(() => undefined);
return () => controller.abort();
}, [cached, subjectId]);
return (
<div className="chat-natal-chart">
{chart && Drawing ? (
<Drawing chart={chart} ariaLabel="本命盘" />
) : (
<div role="img" aria-label="本命盘" />
)}
</div>
);
}
@@ -1,5 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
export function ConversationFollowUps({
questions,
disabled = false,
@@ -12,16 +14,21 @@ export function ConversationFollowUps({
if (questions.length === 0) return null;
return (
<div className="conversation-follow-ups" aria-label="可以继续问" data-onboarding-anchor="follow-up">
{questions.map((question) => (
<button
{questions.map((question, index) => (
<Button
key={question}
className="conversation-follow-up"
variant="outline"
size="sm"
type="button"
disabled={disabled}
data-onboarding-press=""
data-onboarding-enter={question}
data-onboarding-index={index}
onClick={() => onSelect(question)}
>
{question}
</button>
</Button>
))}
</div>
);
@@ -4,6 +4,7 @@ import type { FormEvent } from "react";
import { BirthTimeAssessmentOverlay, type BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
import { BirthLocationFields } from "@/components/birth-location-fields";
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { rememberSkyOrigin } from "@/components/birth-sky/sky-origin";
import { OnboardingChatMessage } from "@/components/onboarding-chat-message";
import { OnboardingWhoStep } from "@/components/onboarding-who-step";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
@@ -80,24 +81,31 @@ export function HomeOnboardingShell({
{!profileComplete ? (
<ol className="onboarding-progress" aria-label="建档进度">
{ONBOARDING_PROGRESS.map((step, index) => (
<li key={step} className={index <= progressIndex ? "is-done" : undefined} />
<li
key={step}
className={index <= progressIndex ? "is-done" : undefined}
data-onboarding-draw-part={step}
data-onboarding-index={index}
/>
))}
</ol>
) : null}
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={reader} />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={whoQuestion(reader)} streaming={onboardingStep === "who" && !profileComplete} length={presetMessageLength} />}
{pastWho && <OnboardingChatMessage role="user" text={choiceText} />}
{pastWho && <OnboardingChatMessage role="assistant" text={birthQuestion(subjectName, audience)} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} />}
{pastBirth && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} />}
{pastBirth && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft, audience, subjectName)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} />}
<OnboardingChatMessage role="assistant" text={presetOnboardingMessage} streaming={onboardingStep === "name" && !profileComplete} length={presetMessageLength} enterKey="intake-greet" enterIndex={0} />
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="user" text={reader} enterKey="intake-name" enterIndex={1} follow="composer" />}
{(onboardingStep !== "name" || profileComplete) && <OnboardingChatMessage role="assistant" text={whoQuestion(reader)} streaming={onboardingStep === "who" && !profileComplete} length={presetMessageLength} enterKey="intake-who" enterIndex={2} />}
{pastWho && <OnboardingChatMessage role="user" text={choiceText} enterKey="intake-choice" enterIndex={3} />}
{pastWho && <OnboardingChatMessage role="assistant" text={birthQuestion(subjectName, audience)} streaming={onboardingStep === "birth" && !profileComplete} length={presetMessageLength} enterKey="intake-birth" enterIndex={4} />}
{pastBirth && <OnboardingChatMessage role="user" text={formatBirthMoment(profileDraft)} enterKey="intake-moment" enterIndex={5} />}
{pastBirth && <OnboardingChatMessage role="assistant" text={placeQuestion(profileDraft, audience, subjectName)} streaming={onboardingStep === "place" && !profileComplete} length={presetMessageLength} enterKey="intake-place" enterIndex={6} />}
{!profileComplete && onboardingStep === "who" && onboardingCardReady && onChooseSelf && onChooseOther ? (
<OnboardingWhoStep saving={profileSaving} onChooseSelf={onChooseSelf} onChooseOther={onChooseOther} />
<div data-onboarding-enter="who-card" data-onboarding-index={2}>
<OnboardingWhoStep saving={profileSaving} onChooseSelf={onChooseSelf} onChooseOther={onChooseOther} />
</div>
) : null}
{!profileComplete && onboardingStep === "birth" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal" data-onboarding-enter="birth-card" data-onboarding-index={3}>
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingBirth} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading">
@@ -109,7 +117,7 @@ export function HomeOnboardingShell({
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
<div className="onboarding-card-actions">
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
<button className="button-primary" type="submit" data-onboarding-press="" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
</div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
@@ -119,13 +127,13 @@ export function HomeOnboardingShell({
)}
{!profileComplete && onboardingStep === "place" && onboardingCardReady && (
<div className="onboarding-card-reveal">
<div className="onboarding-card-reveal" data-onboarding-enter="place-card" data-onboarding-index={4}>
<div className="onboarding-card-reveal-inner">
<form className="profile-form onboarding-card onboarding-step-card birth-time-transition-card" onSubmit={saveOnboardingPlace} aria-busy={birthTimeAssessmentPhase !== null}>
<div className="onboarding-card-heading"><b>出生地点</b><small>搜索全球城市或区县,系统会匹配出生当日的时区。</small></div>
<fieldset className="birth-time-transition-fields" disabled={birthTimeAssessmentPhase !== null}>
<BirthLocationFields value={profileDraft} onChange={setProfileDraft} />
<div className="onboarding-card-actions"><button className="button-primary" type="submit" disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
<div className="onboarding-card-actions"><button className="button-primary" type="submit" data-onboarding-press="" onClick={(event) => rememberSkyOrigin(event.clientX, event.clientY)} disabled={profileSaving || !selectedBirthPlace(profileDraft)}>{profileSaving ? "保存中" : "确定"}</button></div>
</fieldset>
<BirthTimeAssessmentOverlay phase={birthTimeAssessmentPhase} />
</form>
@@ -2,6 +2,8 @@
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* The "back to the newest turn" control both chat surfaces show once the
* reader has scrolled away from the bottom. It hangs off the composer's top
@@ -11,15 +13,16 @@ import { ArrowDown } from "lucide-react";
export function JumpToLatestButton({ onClick }: Readonly<{ onClick: () => void }>) {
return (
<div className="jump-to-latest">
<button
<Button
type="button"
variant="outline"
className="jump-to-latest__button"
aria-label="跳到最新"
onClick={onClick}
>
<ArrowDown aria-hidden="true" />
跳到最新
</button>
</Button>
</div>
);
}
+29 -58
View File
@@ -1,10 +1,15 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check, ChevronDown } from "lucide-react";
import { useId, useState } from "react";
import type { KeyboardEvent } from "react";
import { ChevronDown } from "lucide-react";
import type { PublicLanguageModel } from "@/lib/public-models";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu-lazy";
type ModelSelectorProps = {
readonly models: readonly PublicLanguageModel[];
@@ -19,69 +24,35 @@ export function ModelSelector({
disabled = false,
onSelect,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const groupName = useId();
const selectedModel = models.find((model) => model.id === selectedModelId);
const unavailable = models.length === 0;
function moveRadioSelection(event: KeyboardEvent<HTMLInputElement>, index: number) {
const step = event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (step === 0 || models.length < 2) return;
event.preventDefault();
const nextIndex = (index + step + models.length) % models.length;
const nextModel = models[nextIndex];
if (!nextModel) return;
onSelect(nextModel.id);
setOpen(false);
}
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger
<DropdownMenu>
<DropdownMenuTrigger
className="model-selector-trigger"
type="button"
disabled={disabled || unavailable}
aria-label={selectedModel ? `当前模型:${selectedModel.label},点击切换` : "模型暂不可用"}
>
<b>{selectedModel?.label ?? "暂不可用"}</b>
<ChevronDown aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="model-selector-positioner" side="top" align="start" sideOffset={8} collisionPadding={12}>
<Popover.Popup className="model-selector-popup">
<Popover.Title className="model-selector-title">选择模型</Popover.Title>
<fieldset className="model-selector-options">
<legend className="sr-only">选择当前对话使用的模型</legend>
{models.map((model, index) => (
<label className="model-selector-option" data-selected={model.id === selectedModelId ? "" : undefined} key={model.id}>
<input
className="sr-only"
type="radio"
name={groupName}
value={model.id}
checked={model.id === selectedModelId}
onChange={() => onSelect(model.id)}
onClick={() => {
if (model.id === selectedModelId) onSelect(model.id);
setOpen(false);
}}
onKeyDown={(event) => moveRadioSelection(event, index)}
/>
<span className="model-selector-copy">
<b>{model.label}</b>
</span>
<Check className="model-selector-check" aria-hidden="true" />
</label>
))}
</fieldset>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</DropdownMenuTrigger>
<DropdownMenuContent className="model-selector-popup" side="top" align="start" sideOffset={8}>
<DropdownMenuLabel className="model-selector-title">选择模型</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={selectedModelId}
onValueChange={(modelId) => onSelect(modelId)}
>
{models.map((model) => (
<DropdownMenuRadioItem className="model-selector-option" key={model.id} value={model.id}>
<span className="model-selector-copy">
<b>{model.label}</b>
<small>{model.creditCost} 点</small>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -4,11 +4,35 @@ import { ChatMessageContent } from "@/components/chat-message-content";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
import type { Message } from "@/lib/home-types";
export function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) {
export function OnboardingChatMessage({
role,
text,
streaming = false,
length = text.length,
phraseSafe = false,
enterKey,
enterIndex = 0,
follow,
}: {
role: Message["role"];
text: string;
streaming?: boolean;
length?: number;
phraseSafe?: boolean;
enterKey?: string;
enterIndex?: number;
follow?: "composer";
}) {
const visibleText = streaming ? text.slice(0, length) : text;
const protectedVisibleText = protectOnboardingPhrases(visibleText);
return (
<article className={`message message-${role} onboarding-message${phraseSafe ? " is-phrase-safe" : ""}`} aria-label={role === "assistant" ? "Jyotisha" : "你"}>
<article
className={`message message-${role} onboarding-message${phraseSafe ? " is-phrase-safe" : ""}`}
aria-label={role === "assistant" ? "Jyotisha" : "你"}
data-onboarding-enter={enterKey}
data-onboarding-index={enterKey ? enterIndex : undefined}
data-onboarding-follow={follow}
>
<div className="message-content">
<div className="message-bubble">
{role === "assistant" ? (
@@ -19,12 +19,20 @@ import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
function ChecklistRows({ items, onGo }: { readonly items: readonly ChecklistItem[]; readonly onGo: (item: ChecklistItem) => void }) {
return (
<ul className="onboarding-checklist-rows">
{items.map((item) => (
<li key={item.id} className="onboarding-checklist-row" data-done={item.done ? "true" : "false"}>
{item.done ? <Check aria-hidden="true" /> : <span className="onboarding-checklist-mark" aria-hidden="true" />}
{items.map((item, index) => (
<li
key={item.id}
className="onboarding-checklist-row"
data-done={item.done ? "true" : "false"}
data-onboarding-enter={`checklist-${item.id}`}
data-onboarding-index={index}
>
{item.done ? (
<span data-onboarding-pop={`check-${item.id}`}><Check aria-hidden="true" /></span>
) : <span className="onboarding-checklist-mark" aria-hidden="true" />}
<span className="onboarding-checklist-copy">{item.label}</span>
{!item.done && item.go ? (
<Button variant="outline" size="sm" onClick={() => onGo(item)}>去做</Button>
<Button variant="outline" size="sm" data-onboarding-press="" onClick={() => onGo(item)}>去做</Button>
) : <span />}
</li>
))}
@@ -50,7 +58,7 @@ function ChecklistBody({
<div className="onboarding-checklist-panel">
<h2 className="onboarding-checklist-title">{CHECKLIST_CELEBRATION_TITLE}</h2>
<p className="onboarding-checklist-body">{CHECKLIST_CELEBRATION_BODY}</p>
<Button variant="outline" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
<Button variant="outline" data-onboarding-press="" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
</div>
);
}
@@ -58,7 +66,7 @@ function ChecklistBody({
<div className="onboarding-checklist-panel">
<h2 className="sr-only">{label}</h2>
<ChecklistRows items={items} onGo={onGo} />
<Button variant="ghost" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
<Button variant="ghost" data-onboarding-press="" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
</div>
);
}
@@ -82,6 +90,7 @@ export function OnboardingChecklistEntry() {
useEffect(() => {
if (!journey?.celebration || !ready || celebrated.current) return;
celebrated.current = true;
window.dispatchEvent(new Event("onboarding-celebrate"));
queueMicrotask(() => {
setOpen(true);
journey.setChecklistOpen(true);
@@ -123,7 +132,7 @@ export function OnboardingChecklistEntry() {
if (!ready) {
return (
<Button variant="ghost" className="onboarding-checklist-trigger" disabled>
<Button variant="ghost" className="onboarding-checklist-trigger" data-onboarding-press="" disabled>
{face}
</Button>
);
@@ -132,7 +141,7 @@ export function OnboardingChecklistEntry() {
if (mobile) {
return (
<Drawer open={open} onOpenChange={changeOpen} swipeDirection="down" showSwipeHandle>
<DrawerTrigger className="onboarding-checklist-trigger">{face}</DrawerTrigger>
<DrawerTrigger className="onboarding-checklist-trigger" data-onboarding-press="">{face}</DrawerTrigger>
<DrawerContent className="onboarding-checklist-sheet">
<DrawerHeader className="sr-only">
<DrawerTitle>{journey.celebration ? CHECKLIST_CELEBRATION_TITLE : journey.label}</DrawerTitle>
@@ -147,7 +156,7 @@ export function OnboardingChecklistEntry() {
return (
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger className="onboarding-checklist-trigger">{face}</PopoverTrigger>
<PopoverTrigger className="onboarding-checklist-trigger" data-onboarding-press="">{face}</PopoverTrigger>
<PopoverContent align="end" side="bottom" sideOffset={8} className="onboarding-checklist-popup">
{journey.celebration ? (
<PopoverDescription className="sr-only">{CHECKLIST_CELEBRATION_BODY}</PopoverDescription>
+3 -3
View File
@@ -2,12 +2,12 @@
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
export function OnboardingDot() {
return <span className="onboarding-dot" aria-hidden="true" />;
export function OnboardingDot({ playKey = "menu" }: { readonly playKey?: string }) {
return <span className="onboarding-dot" data-onboarding-blink={playKey} aria-hidden="true" />;
}
export function OnboardingChartDot() {
const journey = useOnboardingJourneyOptional();
if (!journey?.showChartDot) return null;
return <OnboardingDot />;
return <OnboardingDot playKey="menu" />;
}
@@ -90,9 +90,10 @@ export function OnboardingHintLayer() {
sideOffset={8}
className="onboarding-hint"
data-onboarding-hint=""
data-onboarding-pop="hint"
>
<p>{journey.hintText}</p>
<Button variant="outline" size="sm" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
<Button variant="outline" size="sm" data-onboarding-press="" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
</PopoverContent>
</Popover>
);
@@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
import { mountOnboardingMotion } from "@/lib/motion/onboarding-run";
/** Listens for first-appearance beats. Motion itself loads on the first one. */
export function OnboardingMotionHost() {
useEffect(() => mountOnboardingMotion(document), []);
return null;
}
@@ -1,22 +1,38 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { playRing } from "@/lib/motion/onboarding-run";
import { ringSectorClip } from "@/lib/motion/onboarding";
export function OnboardingProgressRing({ done, total }: { readonly done: number; readonly total: number }) {
const radius = 7;
const circumference = 2 * Math.PI * radius;
const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
const valueRef = useRef<HTMLSpanElement>(null);
const seen = useRef<number | null>(null);
useLayoutEffect(() => {
const node = valueRef.current;
if (!node) return;
const next = ringSectorClip(ratio);
const previous = seen.current;
seen.current = ratio;
if (previous === null || previous === ratio) {
node.style.clipPath = next;
return;
}
void playRing(node, ringSectorClip(previous), next);
}, [ratio]);
return (
<svg className="onboarding-progress-ring" viewBox="0 0 18 18" aria-hidden="true">
<circle cx="9" cy="9" r={radius} fill="none" stroke="currentColor" strokeOpacity="0.28" strokeWidth="2" />
<circle
cx="9"
cy="9"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={circumference * (1 - ratio)}
transform="rotate(-90 9 9)"
/>
</svg>
<span className="onboarding-progress-ring" aria-hidden="true">
<svg viewBox="0 0 18 18">
<circle cx="9" cy="9" r="7" fill="none" stroke="currentColor" strokeOpacity="0.28" strokeWidth="2" />
</svg>
<span className="onboarding-progress-ring-value" ref={valueRef}>
<svg viewBox="0 0 18 18">
<circle cx="9" cy="9" r="7" fill="none" stroke="currentColor" strokeWidth="2" />
</svg>
</span>
</span>
);
}
@@ -3,8 +3,13 @@
import { Gift, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { createPortal } from "react-dom";
import { keepFocusWithin } from "@/lib/focus-trap";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { notifyBalanceChanged, redeemErrorMessage } from "@/lib/membership";
import { notifyError, notifySuccess } from "@/lib/notify";
@@ -70,24 +75,18 @@ export function OnboardingRedeemPaywall({
}
}
const overlay = (
<div className="account-modal-overlay" onMouseDown={close}>
<section
return (
<Dialog open onOpenChange={(next) => { if (!next) close(); }}>
<DialogContent
className="account-modal paywall-modal"
role="dialog"
aria-modal="true"
showCloseButton={false}
aria-labelledby="onboarding-paywall-title"
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") close();
else keepFocusWithin(event.nativeEvent, event.currentTarget);
}}
>
<header className="account-modal-header">
<h2 id="onboarding-paywall-title">解锁完整咨询</h2>
<button className="dialog-close" aria-label="关闭" type="button" onClick={close} disabled={pending}>
<DialogTitle id="onboarding-paywall-title">解锁完整咨询</DialogTitle>
<Button className="dialog-close" aria-label="关闭" type="button" variant="ghost" size="icon-sm" onClick={close} disabled={pending}>
<X aria-hidden="true" />
</button>
</Button>
</header>
<div className="paywall-intro">
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
@@ -100,7 +99,7 @@ export function OnboardingRedeemPaywall({
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
<label htmlFor="onboarding-redeem-code">兑换码</label>
<div>
<input
<Input
id="onboarding-redeem-code"
ref={inputRef}
autoComplete="off"
@@ -111,22 +110,20 @@ export function OnboardingRedeemPaywall({
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
<Button className="button-primary" type="submit" disabled={!code.trim() || pending || unlocked}>
{pending ? "兑换中" : unlocked ? "已解锁" : "兑换并继续"}
</button>
</Button>
</div>
</form>
<div className="paywall-footer">
<span>{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"}</span>
{unlocked ? (
<button className="button-primary" type="button" onClick={close}>开始咨询</button>
<Button className="button-primary" type="button" onClick={close}>开始咨询</Button>
) : (
<button className="button-secondary" type="button" onClick={onOpenBilling}>查看套餐与会员</button>
<Button className="button-secondary" type="button" variant="outline" onClick={onOpenBilling}>查看套餐与会员</Button>
)}
</div>
</section>
</div>
</DialogContent>
</Dialog>
);
if (typeof document === "undefined") return overlay;
return createPortal(overlay, document.body);
}
@@ -38,6 +38,7 @@ export function OnboardingWhoStep({
type="button"
variant="outline"
className="onboarding-who-option"
data-onboarding-press=""
disabled={saving}
onClick={onChooseSelf}
>
@@ -48,6 +49,7 @@ export function OnboardingWhoStep({
type="button"
variant="outline"
className="onboarding-who-option"
data-onboarding-press=""
aria-expanded={helping}
disabled={saving}
onClick={() => setHelping(true)}
@@ -76,7 +78,7 @@ export function OnboardingWhoStep({
}}
>
{PERSON_RELATIONS.map((item) => (
<Label key={item} className="onboarding-relation">
<Label key={item} className="onboarding-relation" data-onboarding-press="">
<RadioGroupItem value={item} />
{PERSON_RELATION_LABELS[item]}
</Label>
@@ -86,6 +88,7 @@ export function OnboardingWhoStep({
<div className="onboarding-card-actions">
<Button
type="button"
data-onboarding-press=""
disabled={!canSaveOther}
onClick={() => {
if (relation === "") return;
@@ -421,7 +421,7 @@ export function PeopleArchiveView({
<div className="people-archive" data-view={view}>
<section className="people-archive-list" aria-label="星盘档案">
<ul className="people-archive-rows">
{people.map((person) => {
{people.map((person, index) => {
const current = person.id === subjectId;
const open = person.id === selected?.id && !editing;
return (
@@ -430,6 +430,9 @@ export function PeopleArchiveView({
type="button"
className={`people-archive-row${current ? " is-current" : ""}${open ? " is-open" : ""}`}
aria-current={current ? "true" : undefined}
data-onboarding-press=""
data-onboarding-enter={`archive-${person.id}`}
data-onboarding-index={index}
onClick={() => onSelect(person.id)}
>
<PeopleAvatar person={person} />
@@ -475,7 +478,7 @@ export function PeopleArchiveView({
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
/>
) : selected ? (
<div className="people-archive-card">
<div className="people-archive-card" data-onboarding-enter={`archive-card-${selected.id}`} data-onboarding-index={0}>
<header className="people-archive-head">
<PeopleAvatar person={selected} large />
<div>
@@ -35,6 +35,7 @@ export function PersonRelationField({
type="button"
role="radio"
aria-checked={selected}
data-onboarding-press=""
onClick={() => onChange(option)}
>
{PERSON_RELATION_LABELS[option]}
+47 -38
View File
@@ -1,8 +1,15 @@
"use client";
import { useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Toggle } from "@/components/ui/toggle";
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
import { notifyError, notifySuccess } from "@/lib/notify";
import { saveReplyQualityReason } from "@/lib/reply-ratings";
@@ -34,42 +41,44 @@ export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey
}
return (
<div className="reply-down-reason" role="group" aria-label="不满意原因">
<div className="reply-down-reason-head">
<span>哪里不满意?可以不选</span>
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
</div>
<div className="reply-down-reason-chips">
{REASONS.map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={picked.has(key)}
onClick={() => setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
})}
>
{label}
</button>
))}
</div>
<input
className="reply-down-reason-note"
type="text"
value={note}
maxLength={REPLY_QUALITY_NOTE_MAX}
placeholder="补充一句(可不填)"
aria-label="补充说明"
onChange={(event) => setNote(event.target.value)}
/>
<div className="reply-down-reason-foot">
<span>会把这一轮对话发给我们排查</span>
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
{state === "failed" ? "再试一次" : "提交"}
</button>
</div>
</div>
<Dialog open onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent className="reply-down-reason" showCloseButton={false} aria-label="不满意原因">
<DialogTitle>哪里不满意?可以不选</DialogTitle>
<div className="reply-down-reason-chips">
{REASONS.map(([key, label]) => (
<Toggle
key={key}
type="button"
variant="outline"
size="sm"
pressed={picked.has(key)}
aria-pressed={picked.has(key)}
onPressedChange={() => setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
})}
>
{label}
</Toggle>
))}
</div>
<input
className="reply-down-reason-note"
type="text"
value={note}
maxLength={REPLY_QUALITY_NOTE_MAX}
placeholder="补充一句(可不填)"
aria-label="补充说明"
onChange={(event) => setNote(event.target.value)}
/>
<div className="reply-down-reason-foot">
<DialogDescription>会把这一轮对话发给我们排查</DialogDescription>
<Button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
{state === "failed" ? "再试一次" : "提交"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+5 -2
View File
@@ -2,6 +2,8 @@
import { MoonStar } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* The empty state is split across two slots on purpose.
*
@@ -65,8 +67,9 @@ export function StarterEntries({
return (
<div className="starter-entries">
<div className="starter-entry-row">
<button
<Button
className="starter-entry"
variant="outline"
type="button"
aria-label={natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
disabled={productEntrypointsDisabled}
@@ -74,7 +77,7 @@ export function StarterEntries({
>
<MoonStar aria-hidden="true" />
<span>{dailyLabel}</span>
</button>
</Button>
</div>
{!natalMinuteAvailable && (
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
@@ -27,11 +27,11 @@ export function StarterQuestions({
<div className="starter-questions">
<p className="starter-questions-lead">{starterLead(audience)}</p>
{audience.kind === "other" ? <Badge variant="outline">{starterWatching(audience)}</Badge> : null}
<Badge variant="secondary">{STARTER_FREE_BADGE}</Badge>
<span data-onboarding-pop="free-once"><Badge variant="secondary">{STARTER_FREE_BADGE}</Badge></span>
<div className="starter-questions-list">
<Button type="button" variant="outline" onClick={() => onAsk(questions[0])}>{questions[0]}</Button>
<Button type="button" variant="outline" onClick={() => onAsk(questions[1])}>{questions[1]}</Button>
<Button type="button" variant="outline" onClick={onNudge}>{questions[2]}</Button>
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-0" data-onboarding-index={0} data-onboarding-follow-source="" onClick={() => onAsk(questions[0])}>{questions[0]}</Button>
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-1" data-onboarding-index={1} data-onboarding-follow-source="" onClick={() => onAsk(questions[1])}>{questions[1]}</Button>
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-2" data-onboarding-index={2} onClick={onNudge}>{questions[2]}</Button>
</div>
</div>
);
@@ -131,7 +131,7 @@ export function SynastryReportView(props: {
const title = `${names.a || "甲"} × ${names.b || "乙"} · 婚恋合盘`;
return (
<article className="synastry-report">
<article className="synastry-report" data-onboarding-synastry={props.reportId}>
<Link className="synastry-report-back" href="/synastry">‹ 婚恋合盘</Link>
<header className="synastry-report-heading">
<h1>{title}</h1>
+45 -7
View File
@@ -107,6 +107,17 @@ export type BirthSkyFrame = Readonly<{
text: number;
/** Scattered start positions keyed by `birthSkyBodyKey`. */
scatter: ReadonlyMap<string, SkyPoint>;
/**
* Optional entrance beats. Omitted, or left at identity
* (starLight 1, planetLight 1, ascendantLinks 0, textLines [1, 1],
* convergeOverride 1 while converge is already 1), the painter matches
* the exported cover call for call.
*/
starLight?: number;
planetLight?: number;
ascendantLinks?: number;
textLines?: readonly [number, number];
convergeOverride?: number;
}>;
export function birthSkyBodyKey(kind: "star" | "planet", id: number | string): string {
@@ -147,8 +158,12 @@ export function drawBirthSky(
const palette = resolvePalette(options.palette);
const chromeAlpha = frame?.chrome ?? 1;
const textAlpha = frame?.text ?? 1;
const starLight = frame?.starLight ?? 1;
const planetLight = frame?.planetLight ?? 1;
const place = (key: string, point: SkyPoint): SkyPoint => (
frame ? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.converge) : point
frame
? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.convergeOverride ?? frame.converge)
: point
);
const serif = options.serif ?? true;
const unit = options.width / UNIT_WIDTH;
@@ -178,7 +193,7 @@ export function drawBirthSky(
const visible = new Map<number, SkyPoint>();
ctx.fillStyle = palette.star;
ctx.globalAlpha = 0.85;
ctx.globalAlpha = 0.85 * starLight;
for (const star of sky.stars) {
if (star.altitude <= 0) continue;
const point = place(birthSkyBodyKey("star", star.hr), skyPoint(star.altitude, star.azimuth));
@@ -209,15 +224,19 @@ export function drawBirthSky(
const planetPoint = (planet: (typeof planets)[number]) => (
place(birthSkyBodyKey("planet", planet.id), skyPoint(planet.altitude, planet.azimuth))
);
for (const planet of planets) {
const bodyAlpha = (index: number) => (
planetLight >= 1 ? 1 : Math.min(1, Math.max(0, planetLight * planets.length - index))
);
for (const [index, planet] of planets.entries()) {
const point = planetPoint(planet);
const lit = bodyAlpha(index);
if (planet.id === "sun") {
ctx.fillStyle = palette.sun;
ctx.globalAlpha = 0.18;
ctx.globalAlpha = 0.18 * lit;
ctx.beginPath();
ctx.arc(point.x, point.y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.globalAlpha = lit;
ctx.strokeStyle = palette.sun;
ctx.lineWidth = 1.4;
ctx.beginPath();
@@ -228,12 +247,28 @@ export function drawBirthSky(
ctx.fill();
} else {
ctx.fillStyle = palette.star;
ctx.globalAlpha = 1;
ctx.globalAlpha = lit;
ctx.beginPath();
ctx.arc(point.x, point.y, 3.2, 0, Math.PI * 2);
ctx.fill();
}
}
const linkAlpha = frame?.ascendantLinks ?? 0;
if (linkAlpha > 0) {
const east = skyPoint(0, 90);
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.7;
for (const [index, planet] of planets.entries()) {
const lit = bodyAlpha(index);
if (lit <= 0) continue;
const point = planetPoint(planet);
ctx.globalAlpha = 0.55 * linkAlpha * lit;
ctx.beginPath();
ctx.moveTo(east.x, east.y);
ctx.lineTo(point.x, point.y);
ctx.stroke();
}
}
ctx.restore();
// Planet names sit outside the clip so a body near the horizon keeps its label.
@@ -276,7 +311,7 @@ export function drawBirthSky(
// Text frame (sample C): label, rule, headline … rule, fact, caption, disclaimer.
ctx.textAlign = "left";
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 1 * textAlpha;
ctx.globalAlpha = textAlpha;
ctx.font = font(600, 12, serif);
// Tracked by hand (canvas letterSpacing is not in every Safari we support).
let cursor = 48;
@@ -291,10 +326,13 @@ export function drawBirthSky(
ctx.fillStyle = palette.ink;
ctx.font = font(600, 30, serif);
ctx.globalAlpha = textAlpha * (frame?.textLines?.[0] ?? 1);
ctx.fillText(birthSkyHeadline(sky), 48, 146, 504);
ctx.font = font(600, 15, false);
ctx.globalAlpha = textAlpha * (frame?.textLines?.[1] ?? 1);
ctx.fillText(birthSkyFactLine(sky), 48, 686, 504);
ctx.globalAlpha = textAlpha;
// Birth caption (TASK-birth-sky-polish-20260928 P2), where the footnote used to be.
ctx.fillStyle = palette.inkSecondary;
@@ -0,0 +1,75 @@
/**
* Desktop chat natal panel. localStorage, same reason as the sidebar width:
* `/` is static, so a cookie read on the server would drop that.
* The phone has no panel, and hiding it must not write `open` false.
*/
export const CHAT_CHART_OPEN_STORAGE_KEY = "chat_chart_panel_open";
export const CHAT_CHART_WIDTH_STORAGE_KEY = "chat_chart_panel_width";
export const CHAT_CHART_WIDTH_MIN = 280;
export const CHAT_CHART_WIDTH_MAX = 560;
export const CHAT_CHART_WIDTH_DEFAULT = 360;
export type ChatChartStorage = Pick<Storage, "getItem" | "setItem">;
export function chatChartStorage(): ChatChartStorage | null {
try {
if (typeof localStorage === "undefined") return null;
return localStorage;
} catch {
return null;
}
}
export function clampChatChartWidth(width: number): number {
if (!Number.isFinite(width)) return CHAT_CHART_WIDTH_DEFAULT;
return Math.min(CHAT_CHART_WIDTH_MAX, Math.max(CHAT_CHART_WIDTH_MIN, Math.round(width)));
}
export function readStoredChatChartOpen(
storage: ChatChartStorage | null = chatChartStorage(),
): boolean {
if (storage === null) return false;
try {
return storage.getItem(CHAT_CHART_OPEN_STORAGE_KEY) === "1";
} catch {
return false;
}
}
export function readStoredChatChartWidth(
storage: ChatChartStorage | null = chatChartStorage(),
): number {
if (storage === null) return CHAT_CHART_WIDTH_DEFAULT;
try {
const raw = storage.getItem(CHAT_CHART_WIDTH_STORAGE_KEY);
if (raw === null || raw === "") return CHAT_CHART_WIDTH_DEFAULT;
return clampChatChartWidth(Number(raw));
} catch {
return CHAT_CHART_WIDTH_DEFAULT;
}
}
export function writeStoredChatChartOpen(
open: boolean,
storage: ChatChartStorage | null = chatChartStorage(),
): void {
if (storage === null) return;
try {
storage.setItem(CHAT_CHART_OPEN_STORAGE_KEY, open ? "1" : "0");
} catch {
/* private mode */
}
}
export function writeStoredChatChartWidth(
width: number,
storage: ChatChartStorage | null = chatChartStorage(),
): void {
if (storage === null) return;
try {
storage.setItem(CHAT_CHART_WIDTH_STORAGE_KEY, String(clampChatChartWidth(width)));
} catch {
/* private mode */
}
}
@@ -0,0 +1,13 @@
/** Session memory: a beat plays the first time its key is seen, not on redraw. */
const played = new Set<string>();
export function claimOnboardingPlay(key: string): boolean {
if (played.has(key)) return false;
played.add(key);
return true;
}
export function resetOnboardingPlays(): void {
played.clear();
}
+468
View File
@@ -0,0 +1,468 @@
/**
* Onboarding beats. Loaded with import() so Motion stays out of the first screen.
* Travel is omitted when the user prefers reduced motion, same rule as
* MotionConfig reducedMotion="user": opacity may fade, transforms do not run.
*/
import { claimOnboardingPlay } from "./onboarding-play.ts";
import { loadMotionReact } from "./load-react.ts";
import {
onboardingMotion,
onboardingSpec,
prefersReducedMotion,
ringMotion,
type OnboardingKind,
type OnboardingSpec,
} from "./onboarding.ts";
type AnimateFn = (typeof import("motion/react"))["animate"];
let animateFn: AnimateFn | null = null;
async function animator(): Promise<AnimateFn> {
if (animateFn) return animateFn;
const mod = await loadMotionReact();
animateFn = mod.animate;
return animateFn;
}
function easeOf(spec: OnboardingSpec): [number, number, number, number] {
return [spec.ease[0], spec.ease[1], spec.ease[2], spec.ease[3]];
}
async function run(node: Element, spec: OnboardingSpec, values: Record<string, unknown>): Promise<void> {
const animate = await animator();
await animate(node, values, { duration: spec.duration, delay: spec.delay, ease: easeOf(spec) }).finished;
}
function specFor(kind: OnboardingKind, index = 0, draw?: "intake" | "vedic" | "western" | "dasha"): OnboardingSpec | null {
return onboardingSpec(kind, prefersReducedMotion(), { index, draw });
}
let pointer: { x: number; y: number } | null = null;
let pendingFollow: { text: string; rect: DOMRect; at: number } | null = null;
function indexOf(node: Element): number {
const raw = Number(node.getAttribute("data-onboarding-index") ?? "0");
if (!Number.isFinite(raw) || raw < 0) return 0;
return Math.min(8, Math.floor(raw));
}
async function playEnter(node: HTMLElement): Promise<void> {
const key = node.getAttribute("data-onboarding-enter");
if (!key || !claimOnboardingPlay(`enter:${key}`)) return;
const spec = specFor("enter", indexOf(node));
if (!spec) return;
node.style.opacity = "0";
if (spec.y) node.style.transform = `translateY(${spec.y[0]}px)`;
try {
await run(node, spec, spec.y ? { opacity: [...spec.opacity!], y: [...spec.y] } : { opacity: [...spec.opacity!] });
} finally {
node.style.opacity = "";
node.style.transform = "";
}
}
async function playPop(node: HTMLElement): Promise<void> {
const key = node.getAttribute("data-onboarding-pop");
if (!key || !claimOnboardingPlay(`pop:${key}`)) return;
const spec = specFor("pop");
if (!spec) return;
node.style.opacity = "0";
try {
await run(node, spec, spec.scale
? { opacity: [...spec.opacity!], scale: [...spec.scale] }
: { opacity: [...spec.opacity!] });
} finally {
node.style.opacity = "";
node.style.transform = "";
}
}
async function playBlink(node: HTMLElement): Promise<void> {
const key = node.getAttribute("data-onboarding-blink");
if (!key || !claimOnboardingPlay(`blink:${key}`)) return;
const spec = specFor("blink");
if (!spec?.opacity) return;
await run(node, spec, { opacity: [...spec.opacity] });
}
async function playDraw(node: HTMLElement, kind: "intake" | "vedic" | "western" | "dasha", key: string): Promise<void> {
if (!claimOnboardingPlay(`draw:${key}`)) return;
const spec = specFor("draw", indexOf(node), kind);
if (!spec) return;
if (!spec.clipPath) {
node.style.opacity = "0";
try {
await run(node, spec, { opacity: [0, 1] });
} finally {
node.style.opacity = "";
}
return;
}
await run(node, spec, { clipPath: [...spec.clipPath] });
node.style.clipPath = "";
}
function collect(root: ParentNode, selector: string): HTMLElement[] {
const found = [...root.querySelectorAll<HTMLElement>(selector)];
if (root instanceof HTMLElement && root.matches(selector)) found.unshift(root);
return found;
}
function scan(root: ParentNode): void {
collect(root, "[data-onboarding-enter]").forEach((node) => { void playEnter(node); });
collect(root, "[data-onboarding-pop]").forEach((node) => { void playPop(node); });
collect(root, "[data-onboarding-blink]").forEach((node) => { void playBlink(node); });
collect(root, "[data-onboarding-draw]").forEach((node) => {
const kind = node.getAttribute("data-onboarding-draw");
if (kind === "vedic" || kind === "western" || kind === "dasha") {
void playDraw(node, kind, node.getAttribute("data-onboarding-draw-key") || kind);
}
});
collect(root, "[data-onboarding-draw-part].is-done").forEach((node) => {
const part = node.getAttribute("data-onboarding-draw-part");
if (part) void playDraw(node, "intake", `intake:${part}`);
});
collect(root, "[data-onboarding-synastry]").forEach((node) => { void playSynastry(node); });
collect(root, "[data-onboarding-follow='composer']").forEach((node) => { void playNameFollow(node); });
}
function addedElements(nodes: NodeList): HTMLElement[] {
const found: HTMLElement[] = [];
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
found.push(node);
});
return found;
}
async function playPress(node: HTMLElement): Promise<void> {
const spec = specFor("press");
if (!spec) return;
if (spec.scale) await run(node, spec, { scale: [...spec.scale] });
else if (spec.opacity) await run(node, spec, { opacity: [...spec.opacity] });
node.style.transform = "";
node.style.opacity = "";
}
function capsule(): HTMLElement | null {
return document.querySelector<HTMLElement>(".onboarding-checklist-trigger");
}
async function playCheck(ids: readonly string[]): Promise<void> {
const target = capsule();
const reduced = prefersReducedMotion();
const spec = specFor("check");
for (const [index, id] of ids.entries()) {
if (!claimOnboardingPlay(`check:${id}`)) continue;
if (!spec || reduced || !target) continue;
const from = pointer ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const to = target.getBoundingClientRect();
const ghost = document.createElement("span");
ghost.setAttribute("aria-hidden", "true");
ghost.style.position = "fixed";
ghost.style.left = `${from.x}px`;
ghost.style.top = `${from.y}px`;
ghost.style.width = "16px";
ghost.style.height = "16px";
ghost.style.marginLeft = "-8px";
ghost.style.marginTop = "-8px";
ghost.style.pointerEvents = "none";
ghost.style.zIndex = "80";
ghost.style.color = "var(--color-action)";
ghost.innerHTML = "<svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12.5 10 17.5 19 7.5\"/></svg>";
document.body.appendChild(ghost);
const dx = to.left + to.width / 2 - from.x;
const dy = to.top + to.height / 2 - from.y;
const hop = onboardingSpec("pop", false);
try {
await Promise.all([
run(ghost, { ...spec, delay: index * 0.08 }, {
opacity: [0, 1, 1, 0],
x: [0, dx * 0.55, dx],
y: [0, dy * 0.45 - 72, dy],
}),
hop?.scale ? run(target, hop, { scale: [1, 1.06, 1] }) : Promise.resolve(),
]);
} finally {
ghost.remove();
target.style.transform = "";
}
}
}
async function playCelebrate(): Promise<void> {
if (!claimOnboardingPlay("celebrate")) return;
const spec = specFor("celebrate");
const target = capsule();
if (!spec || !target || !spec.opacity || !spec.y || !spec.scale) return;
const rect = target.getBoundingClientRect();
for (let index = 0; index < 8; index += 1) {
const dot = document.createElement("span");
dot.setAttribute("aria-hidden", "true");
dot.style.position = "fixed";
dot.style.left = `${rect.left + rect.width / 2}px`;
dot.style.top = `${rect.top + rect.height / 2}px`;
dot.style.width = "6px";
dot.style.height = "6px";
dot.style.borderRadius = "999px";
dot.style.background = "var(--color-action)";
dot.style.pointerEvents = "none";
dot.style.zIndex = "80";
document.body.appendChild(dot);
const angle = (Math.PI * 2 * index) / 8;
void run(dot, spec, {
opacity: [...spec.opacity],
x: [0, Math.cos(angle) * 36],
y: [0, Math.sin(angle) * 28 - 8],
scale: [...spec.scale],
}).finally(() => dot.remove());
}
}
async function fadeIn(node: HTMLElement, spec: OnboardingSpec): Promise<void> {
node.style.opacity = "0";
try {
await run(node, spec, { opacity: [0, 1] });
} finally {
node.style.opacity = "";
}
}
async function playNameFollow(node: HTMLElement): Promise<void> {
if (!claimOnboardingPlay("follow:name")) return;
const spec = specFor("follow");
const field = document.querySelector<HTMLElement>(".composer textarea");
if (!spec) return;
if (!field || !spec.x) {
await fadeIn(node, spec);
return;
}
const from = field.getBoundingClientRect();
const to = node.getBoundingClientRect();
const text = node.textContent?.trim() || "";
node.style.opacity = "0";
try {
await flyRect(from, to, text, spec);
} finally {
node.style.opacity = "";
}
}
async function flyRect(from: DOMRect, to: DOMRect, text: string, spec: OnboardingSpec): Promise<void> {
const ghost = document.createElement("span");
ghost.textContent = text.slice(0, 80);
ghost.setAttribute("aria-hidden", "true");
ghost.style.position = "fixed";
ghost.style.left = `${from.left}px`;
ghost.style.top = `${from.top}px`;
ghost.style.maxWidth = "16rem";
ghost.style.pointerEvents = "none";
ghost.style.zIndex = "70";
ghost.style.padding = "4px 10px";
ghost.style.borderRadius = "999px";
ghost.style.background = "var(--color-canvas-muted)";
ghost.style.color = "var(--color-ink)";
document.body.appendChild(ghost);
try {
await run(ghost, spec, {
opacity: spec.opacity ? [...spec.opacity] : [1, 0],
x: [0, to.left - from.left],
y: [0, to.top - from.top],
});
} finally {
ghost.remove();
}
}
async function playStarterFollow(bubble: HTMLElement): Promise<void> {
const pending = pendingFollow;
if (!pending || Date.now() - pending.at > 4000) return;
const text = bubble.textContent?.trim() ?? "";
if (!text || !pending.text || !text.includes(pending.text.slice(0, 12))) return;
pendingFollow = null;
if (!claimOnboardingPlay(`follow:starter:${pending.text}`)) return;
const spec = specFor("follow");
if (!spec) return;
if (!spec.x) {
await fadeIn(bubble, spec);
return;
}
bubble.style.opacity = "0";
try {
await flyRect(pending.rect, bubble.getBoundingClientRect(), pending.text, spec);
} finally {
bubble.style.opacity = "";
}
}
async function playSynastry(article: HTMLElement): Promise<void> {
const id = article.getAttribute("data-onboarding-synastry");
if (!id || !claimOnboardingPlay(`syn:${id}`)) return;
const spec = specFor("synastry");
if (!spec) return;
if (!spec.x || !spec.scale) {
article.style.opacity = "0";
try {
await run(article, spec, { opacity: [0, 1] });
} finally {
article.style.opacity = "";
}
return;
}
const title = article.querySelector("h1")?.textContent ?? "";
const sides = title.split("×").map((part) => part.replace(/·[\s\S]*$/, "").trim());
const marks = [sides[0]?.slice(0, 1) || "甲", sides[1]?.slice(0, 1) || "乙"];
const layer = document.createElement("div");
layer.setAttribute("aria-hidden", "true");
layer.style.position = "fixed";
layer.style.inset = "0";
layer.style.pointerEvents = "none";
layer.style.zIndex = "60";
const left = avatar(marks[0], "left");
const right = avatar(marks[1], "right");
const glow = document.createElement("span");
glow.style.position = "absolute";
glow.style.left = "50%";
glow.style.top = "42%";
glow.style.width = "72px";
glow.style.height = "72px";
glow.style.marginLeft = "-36px";
glow.style.marginTop = "-36px";
glow.style.borderRadius = "999px";
glow.style.border = "1px solid var(--color-action)";
glow.style.opacity = "0";
layer.append(left, right, glow);
document.body.appendChild(layer);
article.style.opacity = "0";
const approach = onboardingMotion.synastryMs / 1000;
try {
await Promise.all([
run(left, { ...spec, duration: approach * 0.4 }, { x: [-80, 0], opacity: [0, 1] }),
run(right, { ...spec, duration: approach * 0.4 }, { x: [80, 0], opacity: [0, 1] }),
]);
await run(glow, { ...spec, duration: approach * 0.25 }, { opacity: [0, 1], scale: [0.6, 1] });
await Promise.all([
run(layer, { ...spec, duration: approach * 0.35 }, { opacity: [1, 0] }),
run(article, { ...spec, duration: approach * 0.35 }, { opacity: [0, 1], scale: [0.98, 1] }),
]);
} finally {
layer.remove();
article.style.opacity = "";
article.style.transform = "";
}
}
function avatar(mark: string, side: "left" | "right"): HTMLSpanElement {
const node = document.createElement("span");
node.textContent = mark;
node.style.position = "absolute";
node.style.top = "42%";
node.style.left = side === "left" ? "calc(50% - 56px)" : "calc(50% + 28px)";
node.style.width = "36px";
node.style.height = "36px";
node.style.display = "grid";
node.style.placeItems = "center";
node.style.borderRadius = "999px";
node.style.background = "var(--color-canvas)";
node.style.color = "var(--color-ink)";
node.style.border = "1px solid var(--color-border)";
return node;
}
async function playHomeRise(): Promise<void> {
if (!claimOnboardingPlay("scene:home")) return;
const spec = specFor("scene-close");
const home = document.querySelector<HTMLElement>(".conversation") ?? document.querySelector<HTMLElement>(".chat-panel");
if (!spec || !home) return;
if (!spec.y) {
return;
}
try {
await run(home, spec, { y: [...spec.y] });
} finally {
home.style.transform = "";
}
}
export async function playRing(node: HTMLElement, from: string, to: string): Promise<void> {
const motion = ringMotion(prefersReducedMotion());
if (!motion.clip) {
node.style.clipPath = to;
return;
}
node.style.clipPath = from;
const spec = specFor("check");
if (!spec) {
node.style.clipPath = to;
return;
}
try {
await run(node, { ...spec, duration: motion.duration, delay: 0 }, { clipPath: [from, to] });
} finally {
node.style.clipPath = to;
}
}
export function mountOnboardingMotion(root: ParentNode = document): () => void {
const visit = (node: HTMLElement) => {
scan(node);
const bubbles = node.matches(".message-user")
? [node]
: [...node.querySelectorAll<HTMLElement>(".message-user")];
bubbles.forEach((bubble) => { void playStarterFollow(bubble); });
};
scan(root);
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "attributes" && record.target instanceof HTMLElement) {
const node = record.target;
if (node.matches("[data-onboarding-draw-part].is-done")) {
const part = node.getAttribute("data-onboarding-draw-part");
if (part) void playDraw(node, "intake", `intake:${part}`);
}
continue;
}
addedElements(record.addedNodes).forEach((node) => visit(node));
}
});
observer.observe(root instanceof Document ? root.body : root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
const onPointer = (event: Event) => {
if (!(event instanceof PointerEvent)) return;
pointer = { x: event.clientX, y: event.clientY };
const pressed = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-onboarding-press]") : null;
if (pressed) void playPress(pressed);
};
const onClick = (event: Event) => {
const source = event.target instanceof Element
? event.target.closest<HTMLElement>("[data-onboarding-follow-source]")
: null;
if (!source) return;
pendingFollow = { text: source.textContent?.trim() ?? "", rect: source.getBoundingClientRect(), at: Date.now() };
};
const onCheck = (event: Event) => {
const ids = (event as CustomEvent<{ ids?: string[] }>).detail?.ids ?? [];
void playCheck(ids);
};
const onCelebrate = () => { void playCelebrate(); };
const onHome = () => { void playHomeRise(); };
document.addEventListener("pointerdown", onPointer, true);
document.addEventListener("click", onClick, true);
window.addEventListener("onboarding-check", onCheck);
window.addEventListener("onboarding-celebrate", onCelebrate);
window.addEventListener("onboarding-sky-home", onHome);
return () => {
observer.disconnect();
document.removeEventListener("pointerdown", onPointer, true);
document.removeEventListener("click", onClick, true);
window.removeEventListener("onboarding-check", onCheck);
window.removeEventListener("onboarding-celebrate", onCelebrate);
window.removeEventListener("onboarding-sky-home", onHome);
};
}
+240
View File
@@ -0,0 +1,240 @@
/** Onboarding motion tokens (T7). Data only; the runner loads Motion on demand. */
import { easeOut, easeSpring, type MotionEase } from "./presets.ts";
export const onboardingMotion = {
pressMs: 120,
enterMs: 260,
staggerMs: 64,
popMs: 380,
followMs: 380,
checkMs: 700,
sceneMs: 680,
synastryMs: 1200,
celebrateMs: 1000,
blinkMs: 800,
reducedMs: 150,
pressScale: 0.96,
enterRise: 12,
drawMs: { intake: 700, vedic: 780, western: 860, dasha: 640 },
} as const;
export const onboardingKinds = [
"press",
"enter",
"pop",
"follow",
"check",
"draw",
"scene-open",
"scene-close",
"synastry",
"celebrate",
"blink",
] as const;
export type OnboardingKind = (typeof onboardingKinds)[number];
export type OnboardingProperty = "opacity" | "transform" | "color" | "clip-path";
export type OnboardingSpec = {
kind: OnboardingKind;
duration: number;
delay: number;
ease: MotionEase;
opacity?: readonly number[];
x?: readonly number[];
y?: readonly number[];
scale?: readonly number[];
clipPath?: readonly [string, string];
properties: readonly OnboardingProperty[];
};
const allowed: readonly OnboardingProperty[] = ["opacity", "transform", "color", "clip-path"];
export function onboardingPropertiesAllowed(spec: OnboardingSpec): boolean {
return spec.properties.every((property) => allowed.includes(property));
}
/** Translation, scale, or a clip-path wipe. Reduced motion must not set these. */
export function onboardingSpecTravels(spec: OnboardingSpec | null): boolean {
if (!spec) return false;
if (spec.x || spec.y || spec.scale) return true;
if (spec.clipPath && spec.clipPath[0] !== spec.clipPath[1]) return true;
return false;
}
function fade(kind: OnboardingKind): OnboardingSpec {
return {
kind,
duration: onboardingMotion.reducedMs / 1000,
delay: 0,
ease: easeOut,
opacity: [0, 1],
properties: ["opacity"],
};
}
export function onboardingSpec(
kind: OnboardingKind,
reduced: boolean,
options?: { index?: number; draw?: keyof typeof onboardingMotion.drawMs },
): OnboardingSpec | null {
if (reduced && (kind === "celebrate" || kind === "blink")) return null;
if (reduced) {
if (kind === "press") {
return {
kind,
duration: onboardingMotion.pressMs / 1000,
delay: 0,
ease: easeOut,
opacity: [1, 0.72, 1],
properties: ["opacity"],
};
}
return fade(kind);
}
const index = options?.index ?? 0;
switch (kind) {
case "press":
return {
kind,
duration: onboardingMotion.pressMs / 1000,
delay: 0,
ease: easeOut,
scale: [1, onboardingMotion.pressScale, 1],
properties: ["transform"],
};
case "enter":
return {
kind,
duration: onboardingMotion.enterMs / 1000,
delay: index * (onboardingMotion.staggerMs / 1000),
ease: easeOut,
opacity: [0, 1],
y: [onboardingMotion.enterRise, 0],
properties: ["opacity", "transform"],
};
case "pop":
return {
kind,
duration: onboardingMotion.popMs / 1000,
delay: 0,
ease: easeSpring,
opacity: [0, 1],
scale: [0.86, 1],
properties: ["opacity", "transform"],
};
case "follow":
return {
kind,
duration: onboardingMotion.followMs / 1000,
delay: 0,
ease: easeOut,
opacity: [0, 1, 1],
x: [0, 1],
y: [0, 1],
properties: ["opacity", "transform"],
};
case "check":
return {
kind,
duration: onboardingMotion.checkMs / 1000,
delay: 0,
ease: easeOut,
opacity: [0, 1, 1, 0],
x: [0, 0.5, 1],
y: [0, -0.35, 1],
properties: ["opacity", "transform"],
};
case "draw":
return {
kind,
duration: onboardingMotion.drawMs[options?.draw ?? "intake"] / 1000,
delay: index * (onboardingMotion.staggerMs / 1000),
ease: easeOut,
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
properties: ["clip-path"],
};
case "scene-open":
return {
kind,
duration: onboardingMotion.sceneMs / 1000,
delay: 0,
ease: easeOut,
clipPath: ["circle(0% at 50% 50%)", "circle(150% at 50% 50%)"],
properties: ["clip-path"],
};
case "scene-close":
return {
kind,
duration: onboardingMotion.sceneMs / 1000,
delay: 0,
ease: easeOut,
opacity: [1, 0],
scale: [1, 1.06],
y: [12, 0],
properties: ["opacity", "transform"],
};
case "synastry":
return {
kind,
duration: onboardingMotion.synastryMs / 1000,
delay: 0,
ease: easeOut,
opacity: [0, 1],
x: [-1, 0],
scale: [0.92, 1],
properties: ["opacity", "transform"],
};
case "celebrate":
return {
kind,
duration: onboardingMotion.celebrateMs / 1000,
delay: 0,
ease: easeOut,
opacity: [0, 1, 0],
y: [0, -28],
scale: [0.4, 1],
properties: ["opacity", "transform"],
};
case "blink":
return {
kind,
duration: onboardingMotion.blinkMs / 1000,
delay: 0,
ease: easeOut,
opacity: [1, 0.25, 1, 0.25, 1],
properties: ["opacity"],
};
default:
return null;
}
}
/** Fixed point count so a ring step can interpolate without touching layout. */
export function ringSectorClip(ratio: number): string {
const steps = 24;
const clamped = Math.min(1, Math.max(0, ratio));
if (clamped <= 0) return `polygon(${Array.from({ length: steps + 2 }, () => "50% 50%").join(", ")})`;
const points = ["50% 50%", "50% 0%"];
for (let index = 1; index <= steps; index += 1) {
const sweep = Math.min(1, clamped * (index / steps));
const angle = -Math.PI / 2 + sweep * Math.PI * 2;
const x = 50 + Math.cos(angle) * 75;
const y = 50 + Math.sin(angle) * 75;
points.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`);
}
return `polygon(${points.join(", ")})`;
}
export function ringMotion(reduced: boolean): { duration: number; clip: boolean } {
if (reduced) return { duration: 0, clip: false };
return { duration: onboardingMotion.checkMs / 1000, clip: true };
}
export function prefersReducedMotion(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
+17
View File
@@ -327,6 +327,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
const ephemerisPhase = useRef<EphemerisPhase>("watch");
const arrivalPhase = useRef<ArrivalPhase>("pending");
const visitedRef = useRef<string | null>(null);
const doneIdsRef = useRef<Set<string> | null>(null);
useEffect(() => {
remoteRef.current = remote;
@@ -377,6 +378,7 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
ephemerisPhase.current = "watch";
arrivalPhase.current = "pending";
visitedRef.current = null;
doneIdsRef.current = null;
queueMicrotask(() => {
setFollowUpArmed(false);
setEphemerisArmed(false);
@@ -544,6 +546,21 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
})
&& (!canDecide || arrivalSettled);
useEffect(() => {
if (!showChecklist) return;
const ids = items.filter((item) => item.done).map((item) => item.id);
const seen = doneIdsRef.current;
if (seen === null) {
doneIdsRef.current = new Set(ids);
return;
}
const fresh = ids.filter((id) => !seen.has(id));
ids.forEach((id) => seen.add(id));
if (fresh.length > 0) {
window.dispatchEvent(new CustomEvent("onboarding-check", { detail: { ids: fresh } }));
}
}, [showChecklist, items]);
const dismissChecklist = useCallback(() => {
const stamp = new Date().toISOString();
setOverlay((current) => ({ ...current, checklistDismissedAt: stamp }));
+5 -1
View File
@@ -27,6 +27,7 @@ import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallbac
import { isListedSidebarSession } from "@/lib/session-list-filter";
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { useSessionStore } from "@/stores/session-store";
export type SessionListBoot = {
readonly sessions: ChatSession[];
@@ -170,7 +171,10 @@ export async function loadSessionList(signal: AbortSignal, forceCatalog = false)
}
export function SessionListProvider({ children }: { children: ReactNode }) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [listOwner] = useState(() => Symbol("session-list"));
useSessionStore.getState().claimSessionList(listOwner);
const sessions = useSessionStore((state) => state.sessions);
const setSessions = useSessionStore((state) => state.setSessions);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [signedOut, setSignedOut] = useState(false);
+74 -2
View File
@@ -1,12 +1,84 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { create } from "zustand";
/** Skeleton. Conversation state moves here in a later issue, not in K0. */
import type { ChatMessageFeedback } from "@/components/chat-message-actions";
import type { ChatSession, StreamingReply } from "@/lib/home-types";
export type ConsultationPhase = "undo" | "streaming" | "recovering" | null;
function apply<T>(current: T, update: SetStateAction<T>): T {
return typeof update === "function" ? (update as (value: T) => T)(current) : update;
}
type SessionState = {
placeholder: true;
sessions: ChatSession[];
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
activeSessionId: string;
setActiveSessionId: Dispatch<SetStateAction<string>>;
consultationPhase: ConsultationPhase;
setConsultationPhase: Dispatch<SetStateAction<ConsultationPhase>>;
cancellationPending: boolean;
setCancellationPending: Dispatch<SetStateAction<boolean>>;
pendingSessionId: string | null;
setPendingSessionId: Dispatch<SetStateAction<string | null>>;
pendingRequestId: string | null;
setPendingRequestId: Dispatch<SetStateAction<string | null>>;
streamingReply: StreamingReply | null;
setStreamingReply: Dispatch<SetStateAction<StreamingReply | null>>;
messageFeedback: Record<string, ChatMessageFeedback>;
setMessageFeedback: Dispatch<SetStateAction<Record<string, ChatMessageFeedback>>>;
copiedMessageKey: string | null;
setCopiedMessageKey: Dispatch<SetStateAction<string | null>>;
warmSeeded: boolean;
/** Reset the fields Home() used to own, once per Home mount. Sessions stay. */
mountHomeConversation: (activeSessionId: string) => void;
/** Reset sessions and the conversation fields when a new list provider mounts. */
claimSessionList: (owner: symbol) => void;
};
export const useSessionStore = create<SessionState>(() => ({
const emptyConversation = {
activeSessionId: "",
consultationPhase: null as ConsultationPhase,
cancellationPending: false,
pendingSessionId: null as string | null,
pendingRequestId: null as string | null,
streamingReply: null as StreamingReply | null,
copiedMessageKey: null as string | null,
warmSeeded: false,
};
let claimedOwner: symbol | null = null;
export const useSessionStore = create<SessionState>((set) => ({
placeholder: true,
sessions: [],
...emptyConversation,
messageFeedback: {},
setSessions: (update) => set((state) => ({ sessions: apply(state.sessions, update) })),
setActiveSessionId: (update) => set((state) => ({ activeSessionId: apply(state.activeSessionId, update) })),
setConsultationPhase: (update) => set((state) => ({ consultationPhase: apply(state.consultationPhase, update) })),
setCancellationPending: (update) => set((state) => ({ cancellationPending: apply(state.cancellationPending, update) })),
setPendingSessionId: (update) => set((state) => ({ pendingSessionId: apply(state.pendingSessionId, update) })),
setPendingRequestId: (update) => set((state) => ({ pendingRequestId: apply(state.pendingRequestId, update) })),
setStreamingReply: (update) => set((state) => ({ streamingReply: apply(state.streamingReply, update) })),
setMessageFeedback: (update) => set((state) => ({ messageFeedback: apply(state.messageFeedback, update) })),
setCopiedMessageKey: (update) => set((state) => ({ copiedMessageKey: apply(state.copiedMessageKey, update) })),
mountHomeConversation: (activeSessionId) => set({
...emptyConversation,
activeSessionId,
messageFeedback: {},
warmSeeded: true,
}),
claimSessionList: (owner) => {
if (claimedOwner === owner) return;
claimedOwner = owner;
set({
sessions: [],
...emptyConversation,
messageFeedback: {},
});
},
}));
+34 -1
View File
@@ -2,19 +2,33 @@
import { create } from "zustand";
import {
CHAT_CHART_WIDTH_DEFAULT,
clampChatChartWidth,
readStoredChatChartOpen,
readStoredChatChartWidth,
writeStoredChatChartOpen,
writeStoredChatChartWidth,
} from "@/lib/chat-chart-panel-state";
import {
readStoredSidebarWidth,
SIDEBAR_WIDTH_DEFAULT,
writeStoredSidebarWidth,
} from "@/lib/sidebar-state";
/** Skeleton plus the desktop sidebar width. Home() does not read this store. */
/** Skeleton plus desktop sidebar width and the chat natal panel. */
type UiState = {
placeholder: true;
sidebarWidth: number;
sidebarWidthHydrated: boolean;
setSidebarWidth: (width: number) => void;
hydrateSidebarWidth: () => void;
chatChartOpen: boolean;
chatChartWidth: number;
chatChartHydrated: boolean;
setChatChartOpen: (open: boolean) => void;
setChatChartWidth: (width: number) => void;
hydrateChatChart: () => void;
};
export const useUiStore = create<UiState>((set) => ({
@@ -32,4 +46,23 @@ export const useUiStore = create<UiState>((set) => ({
sidebarWidthHydrated: true,
});
},
chatChartOpen: false,
chatChartWidth: CHAT_CHART_WIDTH_DEFAULT,
chatChartHydrated: false,
setChatChartOpen: (open) => {
writeStoredChatChartOpen(open);
set({ chatChartOpen: open, chatChartHydrated: true });
},
setChatChartWidth: (width) => {
const next = clampChatChartWidth(width);
writeStoredChatChartWidth(next);
set({ chatChartWidth: next, chatChartHydrated: true });
},
hydrateChatChart: () => {
set({
chatChartOpen: readStoredChatChartOpen(),
chatChartWidth: readStoredChatChartWidth(),
chatChartHydrated: true,
});
},
}));
@@ -66,7 +66,11 @@ test("the account dialog and onboarding paywall portal to document.body, outside
assert.match(overlay, /<AlertDialog open/);
assert.match(read("../src/components/ui/alert-dialog.tsx"), /AlertDialogPrimitive\.Portal/);
assert.match(read("../src/components/ui/dialog.tsx"), /DialogPrimitive\.Portal/);
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
// 原值:兑换窗 `createPortal(overlay, document.body)`
// 新值:兑换窗用 Dialog,传送由 DialogPrimitive.Portal 完成
// 原因:K2 兑换窗改用组件库 Dialog,仍挂到 body,不留在会被 inert 的页面里
assert.match(paywall, /<Dialog open/);
assert.doesNotMatch(paywall, /createPortal\(/);
// 原值:renderToString 得到 role="dialog",且没有 inert
// 新值:注销走 AlertDialog,组件库根把角色写成 alert-dialog;页面源码不写 inert
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CHAT_CHART_OPEN_STORAGE_KEY,
CHAT_CHART_WIDTH_DEFAULT,
CHAT_CHART_WIDTH_MAX,
CHAT_CHART_WIDTH_MIN,
CHAT_CHART_WIDTH_STORAGE_KEY,
clampChatChartWidth,
readStoredChatChartOpen,
readStoredChatChartWidth,
writeStoredChatChartOpen,
writeStoredChatChartWidth,
type ChatChartStorage,
} from "../src/lib/chat-chart-panel-state.ts";
function memoryStorage(seed: Record<string, string> = {}): ChatChartStorage & { values: Map<string, string> } {
const values = new Map(Object.entries(seed));
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, String(value)); },
};
}
test("chat natal panel remembers open and width on this machine", () => {
const storage = memoryStorage();
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT);
writeStoredChatChartOpen(true, storage);
writeStoredChatChartWidth(480, storage);
assert.equal(storage.values.get(CHAT_CHART_OPEN_STORAGE_KEY), "1");
assert.equal(readStoredChatChartOpen(storage), true);
assert.equal(readStoredChatChartWidth(storage), 480);
writeStoredChatChartOpen(false, storage);
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), 480);
});
test("chat natal panel width stays between 280 and 560", () => {
assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN);
assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX);
assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT);
const storage = memoryStorage();
writeStoredChatChartWidth(12, storage);
assert.equal(storage.values.get(CHAT_CHART_WIDTH_STORAGE_KEY), String(CHAT_CHART_WIDTH_MIN));
assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT);
});
test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
assert.match(page, /<ChatChartToggle \/>/);
assert.match(page, /<ChatChartFrame>/);
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
assert.match(frame, /打开本命盘/);
assert.match(frame, /收起本命盘/);
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
assert.match(split, /ResizablePanelGroup/);
assert.match(split, /collapsible/);
assert.doesNotMatch(split, /data-onboarding-draw/);
});
@@ -285,13 +285,16 @@ test("the jump-to-latest control stays keyboard reachable and labelled", () => {
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonTag = sourceBetween(jumpSource, "<button", ">");
// 原值:`<button`
// 新值:`<Button`,仍是 type="button"
// 原因:K2「跳到最新」改用组件库 Button,类名、焦点环和 44px 目标不变
const buttonTag = sourceBetween(jumpSource, "<Button", ">");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpSource, /<button\b/);
assert.match(jumpSource, /<Button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
@@ -64,7 +64,10 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
// 原值:`<Textarea` 直接放在表单里
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
assert.match(composerSource, /<InputGroupTextarea[\s\S]*?value=\{draft\}/);
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
});
@@ -78,7 +81,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
// When: the page still focuses the same textarea element through its own ref.
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
assert.match(pageSource, /inputRef=\{composerInput\}/);
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
// 原值:`<Textarea ref={inputRef}`
// 新值:`<InputGroupTextarea ref={inputRef}`
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
assert.match(composerSource, /<InputGroupTextarea\n\s*ref=\{inputRef\}/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
+4 -1
View File
@@ -64,10 +64,13 @@ test("the redeem paywall opens on the first send refused for credits, not when t
});
test("the onboarding paywall reuses the shared account dialog header", () => {
// 原值:自绘遮罩 `account-modal-overlay`
// 新值:`DialogContent` 使用 `account-modal paywall-modal`,标题仍在 `account-modal-header`
// 原因:K2 兑换窗改用组件库 Dialog,文案和这些类名留下
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
assert.match(onboardingPaywallSource, /<\/header>/);
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.doesNotMatch(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
assert.match(onboardingPaywallSource, /className="dialog-close"/);
});
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { birthSkyEntranceFrame, birthSkySceneStyle } from "../src/components/birth-sky/birth-sky-entrance.ts";
import { birthSkyRevealAt, birthSkyScatter } from "../src/lib/birth-sky/converge.ts";
import {
BIRTH_SKY_EXPORT_HEIGHT,
BIRTH_SKY_EXPORT_WIDTH,
drawBirthSky,
type BirthSkyCanvas,
} from "../src/lib/birth-sky/draw.ts";
import { BIRTH_SKY_REVEAL_TIMEOUT_MS } from "../src/lib/birth-sky/reveal.ts";
import { claimOnboardingPlay, resetOnboardingPlays } from "../src/lib/motion/onboarding-play.ts";
import {
onboardingKinds,
onboardingMotion,
onboardingPropertiesAllowed,
onboardingSpec,
onboardingSpecTravels,
ringMotion,
ringSectorClip,
} from "../src/lib/motion/onboarding.ts";
import { goldenSky } from "./birth-sky-test-support.ts";
const runner = readFileSync(new URL("../src/lib/motion/onboarding-run.ts", import.meta.url), "utf8");
const host = readFileSync(new URL("../src/components/onboarding-motion-host.tsx", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
test("onboarding durations match the T7 table", () => {
assert.equal(onboardingMotion.pressMs, 120);
assert.equal(onboardingMotion.enterMs, 260);
assert.ok(onboardingMotion.staggerMs >= 50 && onboardingMotion.staggerMs <= 80);
assert.equal(onboardingMotion.popMs, 380);
assert.equal(onboardingMotion.followMs, 380);
assert.equal(onboardingMotion.checkMs, 700);
assert.ok(onboardingMotion.sceneMs >= 650 && onboardingMotion.sceneMs <= 700);
assert.equal(onboardingMotion.synastryMs, 1200);
assert.equal(onboardingMotion.celebrateMs, 1000);
for (const ms of Object.values(onboardingMotion.drawMs)) {
assert.ok(ms >= 520 && ms <= 900, String(ms));
}
const pop = onboardingSpec("pop", false);
assert.deepEqual(pop?.ease, [0.34, 1.56, 0.64, 1]);
const press = onboardingSpec("press", false);
assert.deepEqual(press?.ease, [0.22, 1, 0.36, 1]);
assert.equal(press?.scale?.[1], 0.96);
});
test("reduced motion has no travel, and sparkles and blinks are absent", () => {
for (const kind of onboardingKinds) {
const spec = onboardingSpec(kind, true, { index: 3, draw: "vedic" });
assert.equal(onboardingSpecTravels(spec), false, kind);
if (spec) {
assert.equal(onboardingPropertiesAllowed(spec), true, kind);
assert.equal(spec.x, undefined, kind);
assert.equal(spec.y, undefined, kind);
assert.equal(spec.scale, undefined, kind);
assert.equal(spec.clipPath, undefined, kind);
}
}
assert.equal(onboardingSpec("celebrate", true), null);
assert.equal(onboardingSpec("blink", true), null);
assert.equal(ringMotion(true).clip, false);
assert.deepEqual(onboardingSpec("press", true)?.opacity, [1, 0.72, 1]);
const full = onboardingSpec("enter", false, { index: 2 });
assert.equal(onboardingSpecTravels(full), true);
assert.equal(full?.delay, 2 * (onboardingMotion.staggerMs / 1000));
});
test("a beat is claimed once, and the ring clip keeps a stable point count", () => {
resetOnboardingPlays();
assert.equal(claimOnboardingPlay("enter:demo"), true);
assert.equal(claimOnboardingPlay("enter:demo"), false);
resetOnboardingPlays();
const count = (ratio: number) => ringSectorClip(ratio).split(",").length;
assert.equal(count(0), count(0.4));
assert.equal(count(0), count(1));
assert.equal(count(0), 26);
});
test("the runner does not loop, and the home shell does not import Motion directly", () => {
assert.doesNotMatch(runner, /repeat:\s*Infinity|animation:\s*[^;]*infinite/);
assert.doesNotMatch(host, /motion\/react|from ["']motion["']/);
assert.doesNotMatch(layout, /motion\/react|@\/lib\/motion\/provider/);
assert.doesNotMatch(page, /motion\/react|@\/lib\/motion\/provider/);
assert.match(host, /mountOnboardingMotion/);
assert.match(runner, /loadMotionReact/);
assert.equal(BIRTH_SKY_REVEAL_TIMEOUT_MS, 3_000);
});
test("sky entrance stays inside the 6.5s clock and is identity at rest", () => {
assert.equal(birthSkyEntranceFrame(0, true), null);
const start = birthSkyEntranceFrame(0, false);
assert.equal(start?.starLight, 0);
assert.equal(start?.convergeOverride, 0);
assert.equal(start?.planetLight, 0);
assert.equal(start?.ascendantLinks, 0);
const lit = birthSkyEntranceFrame(420, false);
assert.equal(lit?.starLight, 1);
assert.equal(lit?.convergeOverride, 0);
const held = birthSkyEntranceFrame(3_600, false);
assert.equal(held?.starLight, 1);
assert.equal(held?.planetLight, 1);
assert.equal(held?.ascendantLinks, 0);
assert.ok((held?.convergeOverride ?? 0) >= 1);
assert.deepEqual(held?.textLines, [1, 1]);
const open = birthSkySceneStyle(0, false, "reveal", { x: 12, y: 20 }, { w: 100, h: 80 });
assert.match(open.clipPath, /^circle\(0/);
assert.equal(open.notifyHome, false);
const close = birthSkySceneStyle(5_600, false, "reveal", null, { w: 100, h: 80 });
assert.equal(close.notifyHome, true);
assert.match(close.transform, /scale\(/);
const reduced = birthSkySceneStyle(5_600, true, "reveal", { x: 1, y: 2 }, { w: 10, h: 10 });
assert.equal(reduced.clipPath, "");
assert.equal(reduced.transform, "");
assert.equal(reduced.notifyHome, false);
const hold = birthSkySceneStyle(100, false, "hold", { x: 1, y: 2 }, { w: 10, h: 10 });
assert.equal(hold.clipPath, "");
assert.equal(hold.transform, "");
});
type Call = { method: string; args: unknown[]; state: Record<string, unknown> };
function recorder() {
const calls: Call[] = [];
const state: Record<string, unknown> = {};
const ctx = new Proxy({} as Record<string, unknown>, {
get(_target, key: string) {
if (key in state) return state[key];
if (key === "measureText") return (text: string) => ({ width: text.length * 10 });
return (...args: unknown[]) => { calls.push({ method: key, args, state: { ...state } }); };
},
set(_target, key: string, value: unknown) {
state[key] = value;
return true;
},
}) as unknown as BirthSkyCanvas;
return { ctx, calls };
}
test("identity sky extras match the cover call for call", () => {
const sky = goldenSky("night");
const options = { palette: "night" as const, width: BIRTH_SKY_EXPORT_WIDTH, height: BIRTH_SKY_EXPORT_HEIGHT };
const scatter = birthSkyScatter(sky);
const plain = recorder();
drawBirthSky(plain.ctx, sky, options);
const extras = birthSkyEntranceFrame(3_600, false);
assert.ok(extras);
const settled = birthSkyRevealAt(3_600, false).frame;
const withExtras = recorder();
drawBirthSky(withExtras.ctx, sky, options, { ...settled, scatter, ...extras });
assert.deepEqual(withExtras.calls, plain.calls);
});
@@ -9,7 +9,11 @@ class HostElement {
readonly nodeType = 1;
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
readonly childNodes: HostElement[] = [];
readonly style = {};
readonly style = {
setProperty() {},
getPropertyValue() { return ""; },
removeProperty() { return ""; },
};
open = false;
get disabled() { return this.hasAttribute("disabled"); }
isConnected = true;
@@ -22,6 +26,8 @@ class HostElement {
scrollTop = 0;
scrollHeight = 0;
clientHeight = 0;
clientWidth = 1440;
offsetWidth = 1440;
readonly scrollCalls: { top?: number }[] = [];
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
/** No layout here: every box is the origin. Components that position from rects still run. */
@@ -33,10 +39,17 @@ class HostElement {
if (wanted.every(name => classes.includes(name))) return this;
return this.parentNode?.closest(selector) ?? null;
}
get parentElement() { return this.parentNode; }
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
matches() { return false; }
querySelector() { return null; }
querySelectorAll() { return []; }
getRootNode() { return this.ownerDocument; }
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string> = {};
parentNode: HostElement | null = null;
textContent = "";
get nodeValue() { return this.textContent; }
@@ -68,9 +81,12 @@ class HostElement {
class HostDocument {
readonly nodeType = 9;
readonly body: HostElement;
readonly documentElement: HostElement;
activeElement: HostElement | null = null;
constructor() {
this.documentElement = new HostElement("HTML", this);
this.body = new HostElement("BODY", this);
this.documentElement.appendChild(this.body);
}
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
getElementById() { return null; }
@@ -85,6 +101,12 @@ export function createClientLifecycleHarness() {
const document = new HostDocument();
const frames = new Map<number, ReturnType<typeof setTimeout>>();
let frameId = 0;
const requestAnimationFrame = (callback: () => void) => {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
};
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
const window = {
document,
innerWidth: 1440,
@@ -92,21 +114,19 @@ export function createClientLifecycleHarness() {
clearTimeout,
getComputedStyle: () => ({ direction: "ltr" }),
HTMLElement: HostElement,
Element: HostElement,
Node: HostElement,
HTMLIFrameElement: class {},
addEventListener() {},
removeEventListener() {},
requestAnimationFrame(callback: () => void) {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
},
cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); },
requestAnimationFrame,
cancelAnimationFrame,
};
document.defaultView = window;
// tsx's ESM parser binds its runtime to global `self` when that binding
// exists. A partial window makes `cn` fail to parse. Next still calls
// `self.setTimeout`, so `self` is the real global, which already has it.
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true };
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
]));
+12 -5
View File
@@ -4,7 +4,6 @@ import test from "node:test";
import React from "react";
import { toast } from "sonner";
import { ReplyDownReason } from "../src/components/reply-down-reason";
import { PRIVACY } from "../src/lib/legal-documents";
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
@@ -141,7 +140,13 @@ test("the privacy draft says what a 👎 keeps, why, for how long, and that 注
});
type Harness = ReturnType<typeof createClientLifecycleHarness>;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
// 原值:文件顶部静态引入 ReplyDownReason,只在挂载容器里找文案
// 新值:先建好测试文档再动态引入,并同时查找 document.body
// 原因:组件库 Dialog 在模块加载时才决定把内容挂到 body;测试文档必须先就位
const loadReason = () => import("../src/components/reply-down-reason");
const visibleNodes = (h: Harness) => [...h.elements(), ...h.elements(h.document.body)];
const visibleText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => visibleNodes(h).find(predicate);
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
const calls: { url: string; body: Record<string, unknown> }[] = [];
@@ -158,10 +163,11 @@ test("不满意原因 is optional: submitting with nothing picked still thanks t
try {
const key = `${SESSION}:message-5`;
void persistReplyRating(key, "down", "第三答:注意作息。");
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
assert.ok(visibleText(h).includes("哪里不满意?可以不选"));
assert.ok(visibleText(h).includes("会把这一轮对话发给我们排查"));
assert.deepEqual(visibleNodes(h).filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
await h.idle();
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
@@ -188,6 +194,7 @@ test("picked reasons and the note are sent; an unsaved conversation sends nothin
}) as typeof fetch;
const h = createClientLifecycleHarness();
try {
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
+5 -2
View File
@@ -13,8 +13,11 @@ function starterEntryBlock(): string {
return css.slice(start, end);
}
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
test("starter entries stay a 36px pill with a 44px hit area", () => {
// 原值:`<button className="starter-entry"`
// 新值:`<Button className="starter-entry"`
// 原因:K2 今日星语改用组件库 Button,36px 胶囊和 44px 点按区仍由 `.starter-entry` 决定
assert.match(starterHome, /<Button[\s\S]*className="starter-entry"/);
// 原值: 2 新值: 1(生时校正 pill 删除) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 1);
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);