feat(onboarding): first-chart reveal becomes a star convergence, no buttons (F4)

TASK-birth-sky-followup-20260928 F4:
- pure timeline / seeded scatter / interpolation in lib/birth-sky/converge.ts;
  drawBirthSky takes an optional frame (positions interpolated, alphas
  multiplied) so the settled frame is the exported cover call for call
- lazy full-screen <dialog> overlay driven by requestAnimationFrame at
  viewport x DPR; tap / click / Esc end it; reduced motion only fades
- data, serif and overlay chunk must all arrive inside the 3 s budget,
  otherwise skipped silently; page.tsx wiring unchanged
- one sonner hint after it ends; dialog reveal variant (继续, is-reveal) removed
- DESIGN §17, VOICE, CHANGELOG, checklist, PROGRESS with verification

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-28 22:07:01 +08:00
co-authored by Claude Opus 5.5
parent a9a917d50c
commit 731c3b364b
15 changed files with 654 additions and 92 deletions
@@ -144,3 +144,74 @@ JS 的 +773 B 是首页为 T6 引入的 `useBirthSkyReveal`、`startBirthSkyReve
- 打印:`.report-sky-cover-screen` 隐藏、`.report-sky-cover-print` 显示;`.report-sky-cover { break-after: page }`,图高 246mm 居中(A4 减报告 `@page` 边距后版心 186 × 270mm,3:4 在 186mm 宽时 248mm 高)。
- `BLOCKED.md` 的 T5 条目已划掉并注明「→ 已解除:产品 2026-09-28 选 A」。
- 测试:`frontend/tests/report-sky-cover.test.ts`(5 条:coverSubject 有 / 无;封面在正文之上且懒加载、无按钮无等待;night + paper 两张;取数失败 / 空 / 画图失败 / abort 都返回 null;打印 CSS 的 paper 与分页)。
### F4 · 首次建盘群星汇聚过场(替换 T6 弹窗)
- 触发不变:仍只由 `saveOnboardingPlace` 成功后的 `onOwnBirthPlaceSaved?.()` 触发;`saveProfile`、星盘档案、`chart-profile-form` 不触发(原合同测试保留)。`page.tsx` 未改一行(接线 `<BirthSkyReveal cover=… onClose=…/>` 与 `onOwnBirthPlaceSaved: birthSkyReveal.begin` 原样),`Home()` 的 `useState` / `useRef` 数不变。
- 不等待:`startBirthSkyReveal` 改成泛型,仍是 3 秒预算;预算内要到齐三样——`/api/birth-sky?subject=self`、宋体(`waitForHeadingSerif`,最多 1.5 秒)、过场 chunk(`import("@/components/birth-sky/birth-sky-converge")`)。任一失败或超时就静默跳过,无 spinner / 骨架。
- 纯函数 `lib/birth-sky/converge.ts`:`birthSkyScatter`(种子取自这张盘可见天体的投影坐标,mulberry32,均匀撒在地平线圆 95% 半径内)、`birthSkyPositionsAt(sky, scatter, t)`、`interpolateSkyPoint`(在 `draw.ts`,t ≥ 1 精确返回真实点)、`easeOutCubic`、`birthSkyRevealAt(elapsed, reduced)` 时间轴:0–1.5 s 汇聚(ease-out)+ 地平线 / 高度圈 / 方位字 / 连线 / 行星名淡入、整层前 200ms 淡入;1.5–2.5 s 文字 400ms 淡入后停留;2.5–3 s 整层淡出;reduced-motion 不汇聚,400ms 淡入、停留、同时刻淡出。
- `drawBirthSky(ctx, sky, options, frame?)`:新增可选 `frame`,只对位置插值、对透明度乘系数;不传或全为 1 时调用序列与原来逐条相同。
- 过场组件 `components/birth-sky/birth-sky-converge.tsx` + `.css`(懒加载):原生 `<dialog>` 全屏、底色取 `night` palette;画布取视口内最大 3:4 区域 × `devicePixelRatio`;`requestAnimationFrame` 每帧调 `drawBirthSky`;`pointerdown`(点击 / 触摸)与 Esc(`cancel`)立即结束;卸载时 `cancelAnimationFrame`、摘监听、`close()`。无按钮。
- 结束提示:`useBirthSkyReveal.close` 只在过场真的放过后执行一次(`playing` ref 守卫),`toast("这片天空在星盘页可以保存。")`,复用 `layout.tsx` 已挂的 sonner Toaster;这句收进 VOICE.md。失败 / 超时没有过场也没有提示。
- 删除:`BirthSkyDialog` 的 `reveal` 属性、「继续」按钮、`birth-sky.css` 的 `is-reveal` 样式与 `@keyframes birth-sky-reveal`。星盘页弹窗其余不动。
- 四帧 PNG(样稿虚构资料的真实引擎 golden → 同一个 `drawBirthSky`,1080 × 1440,无头 Chrome `canvas.toDataURL` + `--dump-dom` 取出;为排除字体加载时序,这组帧与对照导出都用黑体画):
- `/tmp/claude-1000/-workspace-Jyotisha/fb88e01e-1ced-4bcc-92fe-44c5bdcc878e/scratchpad/birth-sky-converge-night-t0.png`(0 ms,散开)
- 同目录 `birth-sky-converge-night-t050.png`(750 ms,汇聚过半,地平线与方位字半透明)
- 同目录 `birth-sky-converge-night-t100.png`(1500 ms,到位,文字未出)
- 同目录 `birth-sky-converge-night-hold.png`(2400 ms,停留)
- 另有 `day` 一组同名四张。**停留帧与 `drawBirthSky` 导出封面的 PNG dataURL 逐字节相同**(night、day 两组都是 `true`)。
- 测试:`profile-onboarding-birth-sky-reveal.test.ts` 由 5 条改为 10 条(见下「改动的既有断言」)。
### 改动的既有断言(本分支第 3 个提交 `14ef18ac` 新增的测试;基线 `d670521f` 里没有这些测试)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| saved in time…(改名 → 「the convergence starts once its data is ready; ending touches nothing else」) | 载荷类型 `BirthSkyCover`,名字说 dialog | 载荷类型 `BirthSkyRevealSky`(天空 + 宋体结论),断言不变 | F4 把弹窗换成过场,载荷不再是画好的 PNG |
| timeout…(改名 → 「no overlay, the late data is dropped…」) | 同上 | 同上,断言不变 | 同上 |
| failure…(改名 → 「no overlay, no retry, no error surfaced」) | `doesNotMatch(revealHook, /toast\|setError\|console…/)` | `doesNotMatch(revealHook, /setError\|console…/)`,另加:hook 里 `toast(` 恰好 1 处,且不在 `onReady` 里 | F4 要求过场结束后提示一次;失败路径仍然不提示(`close` 只在 `playing` 为真时执行,另有新测试锁住) |
| only the reader's own onboarding place save triggers it… | `revealHook` 匹配 `loadBirthSkyCover("self:reveal", "self", signal)` | 匹配 `loadBirthSkyRevealSky(signal)` | 过场不再需要 PNG,只取天空与字体 |
| the reveal is the chart page's dialog plus 「继续」, faded in 600ms…(**删除**) | 断言弹窗 `reveal` 变体、「继续」、`is-reveal` 600ms 淡入 CSS | 由「overlay: full screen, no buttons…」「timeline…」「the chart page dialog no longer has a reveal variant」三条取代 | 任务书 F4 明令删除弹窗 reveal 变体 |
基线 `d670521f` 的既有断言:无改动。
## 追加单验证(两个提交完成后)
| 项 | 基线 `d670521f` | 交付(第 5 个提交) |
| --- | --- | --- |
| `tsc --noEmit` | 0 错 | 0 错 |
| `npm run lint` | 0 error / 127 warning | 0 error / 127 warning |
| `npm test` | tests 4255 · pass 4200 · fail 24 · cancelled 0 · skipped 31 | tests 4310 · pass 4255 · fail 24 · cancelled 0 · skipped 31 |
| 测试名对比(`frontend/AGENTS.md` 方法) | — | 对基线:消失 0;对本分支第 3 个提交:消失 4(上表 4 条改名 / 删除,均有替代),新增 28 |
| 失败清单 | 24 条(Docker / PostgreSQL 数据库与部署套件) | 与基线逐条同名 |
| `python3 -m pytest tests/test_birth_sky.py tests/test_api_server_growth_contract.py tests/test_personal_report_contract.py` | — | 63 passed |
| `python3 scripts/run_quality_gate.py --profile quick`(Node 22 在 PATH) | — | 所有 Python 步骤通过(pytest 997 passed / 1 skipped);最后一步 `npm test` 的 24 条失败与基线逐条同名 → 退出码 1,属无 Docker 环境缺口 |
| `python3 -m pytest tests/test_repo_privacy_markers.py -q` | — | 通过 |
| `rm -rf .next && next build` | `/` ○ Static | `/` ○ Static;路由表与第 3 个提交交付时逐行相同 |
| `JyotishAPIHandler` 方法数 / `__new__` 计数 | 218 / 不变 | 未改 Python API 服务文件 |
| `Home()` 的 `useState` / `useRef` 数 | — | `page.tsx` 未改 |
### 首屏 gzip(`index.html` 引用的全部 js / css,gzip -9 求和,干净全量构建)
| | 基线 | 第 3 个提交交付 | 本次交付 | 对基线变化 |
| --- | ---: | ---: | ---: | ---: |
| JS | 651,590 B | 652,363 B | 652,605 B | +1,015 B(+0.16%) |
| CSS | 66,346 B | 66,346 B | 66,346 B | 0 |
比第 3 个提交多的 242 B 是首页 hook 里新增的 `sonner` 提示调用、泛型 `startBirthSkyReveal`、`loadBirthSkyRevealSky`;过场组件、`converge.ts`、`draw.ts`、样式都在懒 chunk。报告封面组件在报告页的懒 chunk,不进首页。
## 追加单偏离任务书(请 Claude 定)
1. **指纹只写进报告文档,不写进 v2 evidence bundle。** 任务书写「v2 evidence bundle / 报告文档的 schema 加这个可选字段」。bundle 会原样发给模型(分章写作),把指纹放进去等于把一个出生资料派生值送给供应商,也会改变 bundle 哈希;所以只在组装后的文档(guard 之前)写入,TS / JSON Schema / Python 三处文档合同都加了可选字段。测试断言分章调用发给模型的 bundle 不含指纹。
2. **指纹覆盖姓名。** 同一算法(`resolveServerOwnedChartBirth`)的指纹里本来就含 `name`、岁差等字段,所以改了人物名字或岁差之后,旧报告也会失去封面。这是「与星盘页同一种算法」的直接结果,未另造一个只含出生时刻 / 地点的指纹。
3. **`coverSubject` 只在 ready 读取时下发**(创建响应、replay、失败 / 生成中都不带该键)。
4. **过场用原生 `<dialog>`** 而不是普通 `div`:保证在兑换弹窗(同为顶层)之上,Esc 走 `cancel` 事件;无按钮、无焦点陷阱内容。
5. **整层前 200ms 淡入**(任务书未写),避免保存后页面瞬间变黑;reduced-motion 下整体淡入 400ms。
6. **四帧的「t=0.5」取时间过半(750 ms)**,此时 ease-out 位置进度约 0.875;纯函数测试里的 t 是位置插值参数本身(t=0 散开、t=1 真实)。
7. **报告封面打印高 246mm**(版心 186 × 270mm,3:4 满宽是 248mm,留 2mm 余量防止溢出到第二页)。
## 追加单环境缺口
- 无登录态与真机:报告封面(屏幕 / 打印 PDF)、过场(真机点击跳过、减弱动态效果、慢网)只在单测、源码合同与无头 Chrome 画布取帧上验证;真机清单 `docs/testing/birth-sky-cover-checklist.md` 新增 18–30 条(11、12、13、14、16 作废)。
- 无 Docker:24 条 DB / 部署套件失败与基线逐条同名;quick 门禁最后一步因此退出码 1。本单不改表结构、不加迁移。
- 无模型凭据:真实报告生成(含写入指纹的三条路径)只在注入假代理的单测里跑过,部署后需新生成一份报告复核(清单第 18 条)。
- 未推送、未部署(按任务书由 Claude 验收后整体快进推 staging)。