Compare commits

...
Author SHA1 Message Date
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
34 changed files with 1271 additions and 78 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 建档、清单和天空第一次出现时会动一下
- 建档的句子和卡片、起步问题、追问、档案行,第一次出现时轻轻上浮。按下按钮、问题卡和关系标签时会轻微缩小。
- 称呼和前两个起步问题会从原来的位置飞成气泡。清单每新完成一项,勾飞向右上角的进度环,环走一格。七项刚刚全满时,环上迸一次星点。
- 出生地确认后,天空从点击处展开。星星先亮再聚拢。天空淡出时,首页同时往上浮一点。第一次打开合盘,两个人的头像靠拢后再展开报告。
- 系统开了「减少动态效果」时,这些只剩淡入。星点和圆点闪烁去掉,没有东西飞来飞去。
- Skill 版本不变。
## 2026-10-24 — 侧栏可以拖宽,会话可以拖着排
- 桌面侧栏可以在 200 到 360 像素之间拖宽。拖到最左边会收成一条窄轨,再打开时回到上次的宽度。这个宽度记在这台浏览器里。
@@ -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` 没有追加。
+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 需要产品负责人在真机上看。自动化只证明了时长,以及「减少动态效果」下的规格里没有位移、缩放和擦除。
+22 -6
View File
@@ -4,15 +4,29 @@
桌面侧栏在可拖分栏里,宽 200–360px,默认 288。拖到最左收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。翻页、高亮滑动、拖宽和拖动排序用的库在页面出来后再载入,不进首屏。
## 启程清单与一次性提示(2026-10-24)
## 新用户启程(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 +933,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 +1374,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「首页只揭幕一次」;轨道环揭幕逻辑不动。
+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>
+9
View File
@@ -1138,6 +1138,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 {
+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
@@ -12,12 +12,15 @@ 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) => (
{questions.map((question, index) => (
<button
key={question}
className="conversation-follow-up"
type="button"
disabled={disabled}
data-onboarding-press=""
data-onboarding-enter={question}
data-onboarding-index={index}
onClick={() => onSelect(question)}
>
{question}
@@ -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>
@@ -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>
);
}
@@ -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]}
@@ -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,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 }));
@@ -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);
});