From 731c3b364bb15745ca6d55beaf272ec938fbc85a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 28 Sep 2026 22:07:01 +0800 Subject: [PATCH] feat(onboarding): first-chart reveal becomes a star convergence, no buttons (F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- CHANGELOG.md | 1 + .../PROGRESS-birth-sky-cover-20260928.md | 71 +++++++ docs/testing/birth-sky-cover-checklist.md | 19 +- frontend/DESIGN.md | 15 +- frontend/docs/VOICE.md | 4 +- .../birth-sky/birth-sky-converge.css | 25 +++ .../birth-sky/birth-sky-converge.tsx | 107 ++++++++++ .../components/birth-sky/birth-sky-dialog.tsx | 7 +- .../components/birth-sky/birth-sky-reveal.tsx | 12 +- .../src/components/birth-sky/birth-sky.css | 8 - frontend/src/hooks/use-birth-sky-reveal.ts | 45 +++-- frontend/src/lib/birth-sky/converge.ts | 146 ++++++++++++++ frontend/src/lib/birth-sky/draw.ts | 62 +++++- frontend/src/lib/birth-sky/reveal.ts | 41 ++-- ...rofile-onboarding-birth-sky-reveal.test.ts | 183 +++++++++++++++--- 15 files changed, 654 insertions(+), 92 deletions(-) create mode 100644 frontend/src/components/birth-sky/birth-sky-converge.css create mode 100644 frontend/src/components/birth-sky/birth-sky-converge.tsx create mode 100644 frontend/src/lib/birth-sky/converge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01551fe6..d553221c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 从这次起,新生成的报告会记下生成时用的是哪一套出生资料;打开报告时,只要这套资料和现在一致,正文最上方就有一张「那一刻的天空」封面(深色,和星盘页那张同一种画法)。打印或存 PDF 时封面换成浅色纸面版,单独占第一页。 - 以前生成的报告没有这份记录,永远不显示封面;改过出生时间或出生地之后,改之前生成的报告也不再显示封面。 - 封面拿不到就不显示,不留空白、不转圈,报告正文不受影响。浏览器拿不到出生资料和指纹,只知道「要不要画、画谁」。 +- 第一次建完自己的盘时,原来带「继续」的弹窗换成约 3 秒的群星汇聚过场:星星从散开处汇聚到出生那一刻的真实位置,再显出那句话,停一会儿后整层淡出,回到原本的下一步。过场里没有按钮,点一下或按 Esc 立刻结束;手机开了「减弱动态效果」就只淡入淡出。3 秒内数据没到就不放。放完后首页提示一次「这片天空在星盘页可以保存。」平时打开首页的加载动画不变。 - Skill 版本不 bump(Skill 文本未改)。不改数据库结构。 ## 2026-09-28 — 星盘页新增「那一刻的天空」(待验收) diff --git a/docs/tasks/PROGRESS-birth-sky-cover-20260928.md b/docs/tasks/PROGRESS-birth-sky-cover-20260928.md index 04db4a96..01ab85f5 100644 --- a/docs/tasks/PROGRESS-birth-sky-cover-20260928.md +++ b/docs/tasks/PROGRESS-birth-sky-cover-20260928.md @@ -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` 未改一行(接线 `` 与 `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`(懒加载):原生 `` 全屏、底色取 `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. **过场用原生 ``** 而不是普通 `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)。 diff --git a/docs/testing/birth-sky-cover-checklist.md b/docs/testing/birth-sky-cover-checklist.md index a004252f..2b3bb0ee 100644 --- a/docs/testing/birth-sky-cover-checklist.md +++ b/docs/testing/birth-sky-cover-checklist.md @@ -42,10 +42,19 @@ ## 首次建盘揭幕 +追加单(`TASK-birth-sky-followup-20260928` F4)把弹窗换成了没有按钮的群星汇聚过场,原 13、14、16 条作废,15、17 仍然有效,另按 24 起的新条目走。 + | # | 设备 / 浏览器 | 操作 | 期望 | 结果 | | --- | --- | --- | --- | --- | -| 13 | iPhone Safari | 新账号走完首页引导,最后一步保存出生地 | 3 秒内在当前页面上淡入同一张图(约 0.6 秒),多一个「继续」 | | -| 14 | 同上 | 点「继续」/ Esc / 点遮罩 | 关掉,下面就是原本的下一步(问候或校正引导),没有被换掉 | | -| 15 | 桌面 Chrome | 新账号,保存出生地前在 DevTools → Network → Request blocking 加一条 `*api/birth-sky*`(或把 `/api/birth-sky` 限速到 3 秒以上) | 保存成功后不出现弹层、不报错,引导照常往下走 | | -| 16 | 任一 | 系统开启「减少动态效果」后重复 13 | 弹层直接出现,没有淡入 | | -| 17 | 任一 | 在星盘档案里新建一个家人、或在设置里改自己的出生资料 | 都不弹「那一刻的天空」 | | +| ~~13~~ | ~~iPhone Safari~~ | ~~新账号走完首页引导,最后一步保存出生地~~ | ~~3 秒内在当前页面上淡入同一张图(约 0.6 秒),多一个「继续」~~ | 作废 | +| ~~14~~ | ~~同上~~ | ~~点「继续」/ Esc / 点遮罩~~ | ~~关掉,下面就是原本的下一步(问候或校正引导),没有被换掉~~ | 作废 | +| 15 | 桌面 Chrome | 新账号,保存出生地前在 DevTools → Network → Request blocking 加一条 `*api/birth-sky*`(或把 `/api/birth-sky` 限速到 3 秒以上) | 保存成功后不出现过场、不报错、不转圈,引导照常往下走,也没有「这片天空…」提示 | | +| ~~16~~ | ~~任一~~ | ~~系统开启「减少动态效果」后重复 13~~ | ~~弹层直接出现,没有淡入~~ | 作废 | +| 17 | 任一 | 在星盘档案里新建一个家人、或在设置里改自己的出生资料 | 都不出现「那一刻的天空」过场 | | +| 24 | iPhone Safari | 新账号走完首页引导,最后一步保存出生地,完整看一遍 | 约 3 秒:星星从散开处汇聚到位(约 1.5 秒)→ 主句与事实句淡入并停一会儿 → 整层淡出,露出原本的下一步(问候或校正引导);全程没有按钮 | | +| 25 | 同上(再开一个新账号) | 过场进行中点一下屏幕 | 立刻结束,直接是下一步 | | +| 26 | 桌面 Chrome(新账号) | 过场进行中按 Esc | 立刻结束,直接是下一步 | | +| 27 | iPhone(设置 → 辅助功能 → 动态效果 → 减弱动态效果) | 新账号重复 24 | 星星不移动:整张图淡入、停留、淡出 | | +| 28 | 任一 | 网络很慢或断网时保存出生地(手机可开飞行模式后再关、或 DevTools 限速 Slow 3G) | 过场不出现,引导不卡住,没有转圈 | | +| 29 | 任一 | 24 看完之后 | 首页下方轻提示一次「这片天空在星盘页可以保存。」,只出现一次;刷新首页不再出现,也不再放过场 | | +| 30 | 任一 | 老账号平时打开首页 | 加载动画和以前一样,没有群星过场 | | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 838976d5..0afff78b 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1160,9 +1160,14 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 - **打印 / PDF:** 同一次取数同时画一张 `paper` palette 的图,只在打印时显示;封面独占 A4 第一页(`break-after: page`,高 246mm 居中,恰好放进报告 `@page` 的 186 × 270mm 版心)。不新增打印入口,仍是报告原来的「打印」。 - **取数与等待:** 组件与正文同一次渲染挂载,副作用在正文上屏之后才发请求;两张图都画好才出现。取数、画图任一失败就不显示封面,不留占位、不加等待态,正文不受影响。组件与样式在懒加载 chunk 里(`components/birth-sky/report-sky-cover.tsx`),首页不带。 -### 位置三:首次建盘揭幕 +### 位置三:首次建盘群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928 F4,替换原弹窗) -- 触发只有一处:首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功之后)。每个账户只发生一次,所以没有「看过」标记、不写库。星盘档案里新建家人、设置里改资料都不触发。 -- 保存成功后,onboarding 先照常走到下一步,再去取 `/api/birth-sky?subject=self` 并画图;**3 秒内**画好就在当前页面之上淡入同一个弹层(600ms 透明度,`prefers-reduced-motion` 时直接出现),多一个主按钮「继续」。超时或失败静默跳过:不提示、不重试、不等待。 -- 「继续」、「关闭」、Esc、点遮罩都只关掉弹层,下面就是原本的下一步(问候或校正引导),下一步不因它改变。onboarding 保存出生地后本来不会自动进入出生时间校正,所以不存在「关闭后再进入」的排队。0 点数账户的兑换弹窗若同时出现,天空弹层在它之上(原生 `` 顶层),关掉后兑换弹窗仍在。 -- 这是用户操作之后的结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;首页加载揭幕的轨道环逻辑不动。逻辑在 `hooks/use-birth-sky-reveal.ts`(自持状态),`page.tsx` 只接线(`Home()` 的 `useState` / `useRef` 数不变)。 +- **触发不变:** 只有一处——首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功、下一步已设好之后)。每个账户只发生一次,没有「看过」标记、不写库。星盘档案里新建家人、设置里改资料、平时打开首页都不触发。 +- **不等待:** 保存成功后 onboarding 照常走到下一步,同时取 `/api/birth-sky?subject=self`、等宋体(最多 1.5 秒)、加载过场代码;三样都在 **3 秒**内到齐才开始放,否则静默跳过:不提示、不重试、不转圈、不骨架(§9)。过场开始时数据已全部就绪。 +- **时序(约 3 秒):** ① 汇聚约 1.5 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 200ms 淡入,避免页面一下变黑。② 显现文字约 1 秒:顶部小字、细线、主句、事实句、底部说明在前 400ms 淡入,然后停留。③ 淡出约 0.5 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页,或出生时间校正引导),下一步不因它改变。 +- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数;停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。 +- **全屏、无按钮:** 原生 ``(顶层,0 点数账户的兑换弹窗若同时出现,过场在它之上)铺满视口,底色是 `night` 的页底色;画布按视口取最大的 3:4 区域、乘 `devicePixelRatio` 绘制,`requestAnimationFrame` 驱动,卸载时取消。点击、触摸(`pointerdown`)或 Esc 立刻结束,直接进入下一步。 +- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚:成品画面整体淡入(400ms)、停留、同一时刻淡出,总长同样约 3 秒。 +- **结束后的提示:** 过场正常结束或被点掉后,首页用已有的 sonner Toaster 轻提示一次「这片天空在星盘页可以保存。」(VOICE.md)。取数失败、超时时没有过场,也没有提示。 +- **平时的首页加载动画不改。** 这是用户操作之后的一次性结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;轨道环揭幕逻辑不动。 +- 代码:时序与插值是 `lib/birth-sky/converge.ts` 的纯函数;过场组件 `components/birth-sky/birth-sky-converge.tsx`(懒加载 chunk,含 `draw.ts` 与样式);状态在 `hooks/use-birth-sky-reveal.ts`,`page.tsx` 接线未变(`Home()` 的 `useState` / `useRef` 数不变)。星盘页的「那一刻的天空」弹窗删掉了只给揭幕用的 `is-reveal` 样式与「继续」按钮,其余不动。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index c280ad7f..58e0c77f 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -159,7 +159,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语不用星星、月亮一类比喻(上文),那一条只管首页问候。封面写的是出生那一刻真实在天上的天体,不是比喻,两者不冲突。 -弹层文案:标题「那一刻的天空」(也是星盘页按钮字),按钮「保存图片」「关闭」,首次揭幕多一个「继续」。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。 +弹层文案:标题「那一刻的天空」(也是星盘页按钮字),按钮「保存图片」「关闭」。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。 + +首次建盘的群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928)里**没有按钮、没有额外文字**,字就是封面上的主句与事实句;原来弹层上的「继续」已删除。过场结束后首页轻提示一次:「这片天空在星盘页可以保存。」——一句陈述,句号结尾,不加感叹、不加 emoji,不催用户去做。报告封面也不加任何文字或按钮。 ## 七条原则 diff --git a/frontend/src/components/birth-sky/birth-sky-converge.css b/frontend/src/components/birth-sky/birth-sky-converge.css new file mode 100644 index 00000000..e16389a9 --- /dev/null +++ b/frontend/src/components/birth-sky/birth-sky-converge.css @@ -0,0 +1,25 @@ +/* First-chart convergence (TASK-birth-sky-followup-20260928 F4). Loaded with + the lazy overlay chunk only. Full screen, no buttons; opacity is driven + frame by frame from requestAnimationFrame, never from CSS keyframes. The + background is the night palette's floor, set inline from the painter. */ +.birth-sky-converge { + position: fixed; + inset: 0; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + padding: 0; + border: 0; + display: grid; + place-items: center; + overflow: hidden; + cursor: default; + touch-action: none; +} +.birth-sky-converge:not([open]) { display: none; } +.birth-sky-converge::backdrop { background: transparent; } +.birth-sky-converge canvas { display: block; } +@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } } +@media print { .birth-sky-converge { display: none !important; } } diff --git a/frontend/src/components/birth-sky/birth-sky-converge.tsx b/frontend/src/components/birth-sky/birth-sky-converge.tsx new file mode 100644 index 00000000..c1c08426 --- /dev/null +++ b/frontend/src/components/birth-sky/birth-sky-converge.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import "./birth-sky-converge.css"; + +import { birthSkyRevealAt, birthSkyScatter } 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"; +import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence"; + +/** Largest 3:4 box that fits the viewport, in CSS pixels. */ +export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: number) { + const width = Math.max(1, Math.min(viewportWidth, (viewportHeight * 3) / 4)); + return { width, height: (width * 4) / 3 }; +} + +/** + * First-chart convergence (TASK-birth-sky-followup-20260928 F4). A full-screen + * layer with no buttons: the stars converge, the words appear, the layer fades + * and the step underneath (unchanged) is simply there. A tap, a click or Esc + * ends it at once. Every frame is `drawBirthSky` with interpolated positions, + * so the settled frame is the exported cover. + */ +export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) { + const dialog = useRef(null); + const canvas = useRef(null); + + useEffect(() => { + const layer = dialog.current; + const surface = canvas.current; + const context = surface?.getContext("2d"); + if (!layer || !surface || !context) { + onDone(); + return; + } + const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + const scatter = birthSkyScatter(reveal.sky); + let frameHandle = 0; + let started: number | null = null; + let finished = false; + + const fit = () => { + const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight); + const ratio = window.devicePixelRatio || 1; + surface.style.width = `${size.width}px`; + surface.style.height = `${size.height}px`; + surface.width = Math.round(size.width * ratio); + surface.height = Math.round(size.height * ratio); + }; + const finish = () => { + if (finished) return; + finished = true; + cancelAnimationFrame(frameHandle); + onDone(); + }; + const tick = (now: number) => { + if (finished) return; + started ??= now; + const moment = birthSkyRevealAt(now - started, reduced); + layer.style.opacity = String(moment.layer); + drawBirthSky(context as BirthSkyCanvas, reveal.sky, { + palette: "night", + width: surface.width, + height: surface.height, + serif: reveal.serif, + }, { ...moment.frame, scatter }); + if (moment.done) finish(); + else frameHandle = requestAnimationFrame(tick); + }; + const cancel = (event: Event) => { + event.preventDefault(); + finish(); + }; + + fit(); + layer.style.opacity = "0"; + layer.showModal(); + layer.addEventListener("cancel", cancel); + layer.addEventListener("pointerdown", finish); + window.addEventListener("resize", fit); + frameHandle = requestAnimationFrame(tick); + return () => { + finished = true; + cancelAnimationFrame(frameHandle); + layer.removeEventListener("cancel", cancel); + layer.removeEventListener("pointerdown", finish); + window.removeEventListener("resize", fit); + layer.close(); + }; + }, [reveal, onDone]); + + return ( + + + + ); +} diff --git a/frontend/src/components/birth-sky/birth-sky-dialog.tsx b/frontend/src/components/birth-sky/birth-sky-dialog.tsx index 04eda525..9e91350d 100644 --- a/frontend/src/components/birth-sky/birth-sky-dialog.tsx +++ b/frontend/src/components/birth-sky/birth-sky-dialog.tsx @@ -12,8 +12,6 @@ import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence"; export type BirthSkyDialogProps = Readonly<{ cover: BirthSkyCover; onClose: () => void; - /** First-chart reveal: fade in over the current step and offer 「继续」. */ - reveal?: boolean; }>; /** @@ -21,7 +19,7 @@ export type BirthSkyDialogProps = Readonly<{ * centred on desktop. The picture is an `` of the painted PNG, so a long * press saves it on iOS; 「保存图片」 shares or downloads the same file. */ -export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialogProps) { +export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) { const dialog = useRef(null); const saving = useRef(false); const [error, setError] = useState(null); @@ -53,7 +51,7 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo return ( { event.preventDefault(); onClose(); }} onClick={(event) => { if (event.target === event.currentTarget) onClose(); }} @@ -70,7 +68,6 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
{error ?

{error}

: null} - {reveal ? : null}
diff --git a/frontend/src/components/birth-sky/birth-sky-reveal.tsx b/frontend/src/components/birth-sky/birth-sky-reveal.tsx index 707f15c1..e316f638 100644 --- a/frontend/src/components/birth-sky/birth-sky-reveal.tsx +++ b/frontend/src/components/birth-sky/birth-sky-reveal.tsx @@ -2,14 +2,14 @@ import dynamic from "next/dynamic"; -import type { BirthSkyCover } from "@/lib/birth-sky/cover"; +import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal"; -const BirthSkyDialog = dynamic( - () => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog), +const BirthSkyConverge = dynamic( + () => import("./birth-sky-converge").then((module) => module.BirthSkyConverge), { ssr: false }, ); -/** First-chart reveal: the chart page's dialog, faded in once, with 「继续」. */ -export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyCover | null; onClose: () => void }) { - return cover ? : null; +/** First-chart reveal: the stars converge once over the next step, then get out of the way. */ +export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyRevealSky | null; onClose: () => void }) { + return cover ? : null; } diff --git a/frontend/src/components/birth-sky/birth-sky.css b/frontend/src/components/birth-sky/birth-sky.css index b77ca2ec..b3524b1b 100644 --- a/frontend/src/components/birth-sky/birth-sky.css +++ b/frontend/src/components/birth-sky/birth-sky.css @@ -74,12 +74,4 @@ .birth-sky-panel { height: auto; max-height: calc(92vh - 2px); } .birth-sky-image { max-height: calc(92vh - 160px); } } -/* First-chart reveal: one 600ms fade, no motion when the reader asked for none. */ -.birth-sky-dialog.is-reveal[open] { animation: birth-sky-reveal 600ms ease-out; } -.birth-sky-dialog.is-reveal[open]::backdrop { animation: birth-sky-reveal 600ms ease-out; } -@keyframes birth-sky-reveal { from { opacity: 0; } to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { - .birth-sky-dialog.is-reveal[open], - .birth-sky-dialog.is-reveal[open]::backdrop { animation: none; } -} @media print { .birth-sky-dialog { display: none !important; } } diff --git a/frontend/src/hooks/use-birth-sky-reveal.ts b/frontend/src/hooks/use-birth-sky-reveal.ts index fee4b56a..44403162 100644 --- a/frontend/src/hooks/use-birth-sky-reveal.ts +++ b/frontend/src/hooks/use-birth-sky-reveal.ts @@ -1,34 +1,49 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; -import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover"; -import { startBirthSkyReveal } from "@/lib/birth-sky/reveal"; +import { loadBirthSkyRevealSky, startBirthSkyReveal, type BirthSkyRevealSky } from "@/lib/birth-sky/reveal"; + +/** Shown once on the home page after the convergence ends (VOICE.md). */ +export const BIRTH_SKY_REVEAL_HINT = "这片天空在星盘页可以保存。"; /** - * The one-time 「那一刻的天空」 reveal after the reader's own first chart - * (TASK-birth-sky-cover-20260928 T6). Owns its state so Home only wires it: - * `begin` goes to the onboarding hook, `cover` / `close` to the dialog. - * Saving one's own birth place happens once per account, so no "seen" flag. + * The one-time 「那一刻的天空」 convergence after the reader's own first chart + * (TASK-birth-sky-cover-20260928 T6 → TASK-birth-sky-followup-20260928 F4). + * Owns its state so Home only wires it: `begin` goes to the onboarding hook, + * `cover` / `close` to the overlay. `cover` is the sky plus the serif verdict, + * ready before the first frame. Saving one's own birth place happens once per + * account, so no "seen" flag. */ export function useBirthSkyReveal() { - const [cover, setCover] = useState(null); + const [cover, setCover] = useState(null); const cancel = useRef<(() => void) | null>(null); + const playing = useRef(false); const begin = useCallback(() => { cancel.current?.(); - cancel.current = startBirthSkyReveal({ - load: (signal) => loadBirthSkyCover("self:reveal", "self", signal), - onReady: (next) => setCover(next), - onDiscard: releaseBirthSkyCover, + cancel.current = startBirthSkyReveal({ + // The overlay chunk loads inside the same budget, so the first frame never waits for code. + load: async (signal) => { + const [sky] = await Promise.all([ + loadBirthSkyRevealSky(signal), + import("@/components/birth-sky/birth-sky-converge"), + ]); + return sky; + }, + onReady: (next) => { + playing.current = true; + setCover(next); + }, }); }, []); const close = useCallback(() => { - setCover((current) => { - releaseBirthSkyCover(current); - return null; - }); + if (!playing.current) return; + playing.current = false; + setCover(null); + toast(BIRTH_SKY_REVEAL_HINT); }, []); useEffect(() => () => { cancel.current?.(); }, []); diff --git a/frontend/src/lib/birth-sky/converge.ts b/frontend/src/lib/birth-sky/converge.ts new file mode 100644 index 00000000..e8a9d14e --- /dev/null +++ b/frontend/src/lib/birth-sky/converge.ts @@ -0,0 +1,146 @@ +import type { BirthSkyOk } from "./contract.ts"; +import { + BIRTH_SKY_CENTER, + BIRTH_SKY_RADIUS, + birthSkyBodyKey, + interpolateSkyPoint, + skyPoint, + type BirthSkyFrame, + type SkyPoint, +} from "./draw.ts"; + +/** + * First-chart convergence (TASK-birth-sky-followup-20260928 F4): the visible + * stars and planets travel from seeded scattered positions to their true + * projection, then the words appear, then the whole layer fades out. Pure + * functions only; the overlay feeds them `requestAnimationFrame` time. + */ + +export const BIRTH_SKY_CONVERGE_MS = 1_500; +export const BIRTH_SKY_TEXT_MS = 1_000; +export const BIRTH_SKY_FADE_MS = 500; +export const BIRTH_SKY_REVEAL_TOTAL_MS = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS + BIRTH_SKY_FADE_MS; +/** Words fade in over the first part of their second, then hold. */ +const TEXT_FADE_IN_MS = 400; +/** The layer itself comes in quickly so the page does not flash to dark. */ +const LAYER_FADE_IN_MS = 200; +/** Reduced motion: no travel, one fade in, a hold, one fade out. */ +const REDUCED_FADE_IN_MS = 400; +/** Scattered stars stay inside the horizon so the clip never hides them. */ +const SCATTER_RADIUS = BIRTH_SKY_RADIUS * 0.95; + +export type BirthSkyVisibleBody = Readonly<{ key: string; point: SkyPoint }>; + +/** Bodies drawn on the cover (above the horizon), keyed as the painter keys them. */ +export function birthSkyVisibleBodies(sky: BirthSkyOk): readonly BirthSkyVisibleBody[] { + return [ + ...sky.stars + .filter((star) => star.altitude > 0) + .map((star) => ({ key: birthSkyBodyKey("star", star.hr), point: skyPoint(star.altitude, star.azimuth) })), + ...sky.planets + .filter((planet) => planet.altitude > 0) + .map((planet) => ({ key: birthSkyBodyKey("planet", planet.id), point: skyPoint(planet.altitude, planet.azimuth) })), + ]; +} + +/** Seed from the chart itself, so the same chart always scatters the same way. */ +export function birthSkySeed(sky: BirthSkyOk): number { + let hash = 0x811c9dc5; + const feed = (value: string) => { + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + }; + for (const body of birthSkyVisibleBodies(sky)) feed(`${body.key}@${body.point.x.toFixed(3)},${body.point.y.toFixed(3)};`); + return hash >>> 0; +} + +/** mulberry32: small, fast, deterministic. */ +function random(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +/** Uniform scattered start points inside the horizon, one per visible body. */ +export function birthSkyScatter(sky: BirthSkyOk, seed = birthSkySeed(sky)): ReadonlyMap { + const next = random(seed); + const scatter = new Map(); + for (const body of birthSkyVisibleBodies(sky)) { + const radius = Math.sqrt(next()) * SCATTER_RADIUS; + const angle = next() * Math.PI * 2; + scatter.set(body.key, { + x: BIRTH_SKY_CENTER.x + radius * Math.cos(angle), + y: BIRTH_SKY_CENTER.y + radius * Math.sin(angle), + }); + } + return scatter; +} + +/** Positions at progress t (0 = scattered, 1 = true). */ +export function birthSkyPositionsAt( + sky: BirthSkyOk, + scatter: ReadonlyMap, + t: number, +): ReadonlyMap { + const positions = new Map(); + for (const body of birthSkyVisibleBodies(sky)) { + positions.set(body.key, interpolateSkyPoint(scatter.get(body.key) ?? body.point, body.point, t)); + } + return positions; +} + +export function easeOutCubic(t: number): number { + if (t <= 0) return 0; + if (t >= 1) return 1; + return 1 - (1 - t) ** 3; +} + +function clamp01(value: number): number { + return value <= 0 ? 0 : value >= 1 ? 1 : value; +} + +export type BirthSkyRevealTimeline = Readonly<{ + /** Painter frame at this moment. */ + frame: Omit; + /** Opacity of the whole overlay. */ + layer: number; + /** True once the last fade has finished: remove the overlay, show the next step. */ + done: boolean; +}>; + +const SETTLED = { converge: 1, chrome: 1, text: 1 } as const; + +/** + * Normal: 0–1.5 s converge (ease-out) with horizon, rings and letters fading + * in; 1.5–2.5 s words fade in and hold; 2.5–3 s the layer fades out. + * Reduced motion: the finished picture fades in, holds, fades out. + */ +export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline { + const t = Math.max(0, elapsedMs); + const fadeStart = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS; + const done = t >= BIRTH_SKY_REVEAL_TOTAL_MS; + const fadeOut = t >= fadeStart ? 1 - clamp01((t - fadeStart) / BIRTH_SKY_FADE_MS) : 1; + if (reducedMotion) { + return { frame: SETTLED, layer: Math.min(clamp01(t / REDUCED_FADE_IN_MS), fadeOut), done }; + } + if (t < BIRTH_SKY_CONVERGE_MS) { + const progress = t / BIRTH_SKY_CONVERGE_MS; + return { + frame: { converge: easeOutCubic(progress), chrome: progress, text: 0 }, + layer: clamp01(t / LAYER_FADE_IN_MS), + done: false, + }; + } + return { + frame: { converge: 1, chrome: 1, text: clamp01((t - BIRTH_SKY_CONVERGE_MS) / TEXT_FADE_IN_MS) }, + layer: fadeOut, + done, + }; +} diff --git a/frontend/src/lib/birth-sky/draw.ts b/frontend/src/lib/birth-sky/draw.ts index 7f11430d..f5c22f0c 100644 --- a/frontend/src/lib/birth-sky/draw.ts +++ b/frontend/src/lib/birth-sky/draw.ts @@ -92,6 +92,35 @@ export function skyPoint(altitude: number, azimuth: number): SkyPoint { } export const BIRTH_SKY_CENTER: SkyPoint = { x: CENTER_X, y: CENTER_Y }; +/** Horizon radius in design units. */ +export const BIRTH_SKY_RADIUS = RADIUS; + +/** + * One frame of the first-chart convergence (TASK-birth-sky-followup-20260928 F4). + * Omitted, or with every field at 1, the painter draws the exported cover + * exactly: positions are only interpolated, alphas only multiplied. + */ +export type BirthSkyFrame = Readonly<{ + /** Eased progress: 0 = scattered positions, 1 = true projected positions. */ + converge: number; + /** Horizon circle, altitude rings, direction letters, constellation lines, planet names. */ + chrome: number; + /** Text frame: top label, rules, headline, fact line, footnote, disclaimer. */ + text: number; + /** Scattered start positions keyed by `birthSkyBodyKey`. */ + scatter: ReadonlyMap; +}>; + +export function birthSkyBodyKey(kind: "star" | "planet", id: number | string): string { + return `${kind}:${id}`; +} + +/** Pure: t = 0 returns `from`, t = 1 returns `to` exactly (no floating-point residue). */ +export function interpolateSkyPoint(from: SkyPoint, to: SkyPoint, t: number): SkyPoint { + if (t >= 1) return to; + if (t <= 0) return from; + return { x: from.x * (1 - t) + to.x * t, y: from.y * (1 - t) + to.y * t }; +} function resolvePalette(palette: BirthSkyDrawOptions["palette"]): BirthSkyPalette { return typeof palette === "string" ? BIRTH_SKY_PALETTES[palette] : palette; @@ -111,8 +140,18 @@ function overlaps(a: Box, b: Box): boolean { return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; } -export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: BirthSkyDrawOptions): void { +export function drawBirthSky( + ctx: BirthSkyCanvas, + sky: BirthSkyOk, + options: BirthSkyDrawOptions, + frame?: BirthSkyFrame, +): void { const palette = resolvePalette(options.palette); + const chromeAlpha = frame?.chrome ?? 1; + const textAlpha = frame?.text ?? 1; + const place = (key: string, point: SkyPoint): SkyPoint => ( + frame ? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.converge) : point + ); const serif = options.serif ?? true; const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote; const unit = options.width / UNIT_WIDTH; @@ -131,7 +170,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt ctx.strokeStyle = palette.star; ctx.lineWidth = 0.5; - ctx.globalAlpha = 0.22; + ctx.globalAlpha = 0.22 * chromeAlpha; ctx.setLineDash([1, 4]); for (const ring of [30, 60]) { ctx.beginPath(); @@ -145,7 +184,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt ctx.globalAlpha = 0.85; for (const star of sky.stars) { if (star.altitude <= 0) continue; - const point = skyPoint(star.altitude, star.azimuth); + const point = place(birthSkyBodyKey("star", star.hr), skyPoint(star.altitude, star.azimuth)); visible.set(star.hr, point); ctx.beginPath(); ctx.arc(point.x, point.y, starRadius(star.magnitude), 0, Math.PI * 2); @@ -154,7 +193,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt ctx.strokeStyle = palette.star; ctx.lineWidth = 0.6; - ctx.globalAlpha = 0.4; + ctx.globalAlpha = 0.4 * chromeAlpha; for (const line of sky.lines) { for (const stroke of line.strokes) { for (let index = 1; index < stroke.length; index += 1) { @@ -170,8 +209,11 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt } const planets = sky.planets.filter((planet) => planet.altitude > 0); + const planetPoint = (planet: (typeof planets)[number]) => ( + place(birthSkyBodyKey("planet", planet.id), skyPoint(planet.altitude, planet.azimuth)) + ); for (const planet of planets) { - const point = skyPoint(planet.altitude, planet.azimuth); + const point = planetPoint(planet); if (planet.id === "sun") { ctx.fillStyle = palette.sun; ctx.globalAlpha = 0.18; @@ -201,7 +243,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt ctx.font = font(400, 11, false); const labels: Box[] = []; for (const planet of planets) { - const point = skyPoint(planet.altitude, planet.azimuth); + const point = planetPoint(planet); const name = BIRTH_SKY_PLANET_NAMES[planet.id]; const width = 24; const candidates = planet.id === "sun" @@ -214,20 +256,20 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt const choice = candidates.find((item) => !labels.some((taken) => overlaps(box(item), taken))) ?? candidates[0]; labels.push(box(choice)); ctx.fillStyle = planet.id === "sun" ? palette.sun : palette.ink; - ctx.globalAlpha = 0.9; + ctx.globalAlpha = 0.9 * chromeAlpha; ctx.textAlign = choice.align; ctx.fillText(name, point.x + choice.dx, point.y + choice.dy); } ctx.strokeStyle = palette.star; ctx.lineWidth = 0.9; - ctx.globalAlpha = 0.6; + ctx.globalAlpha = 0.6 * chromeAlpha; ctx.beginPath(); ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = palette.inkSecondary; - ctx.globalAlpha = 0.9; + ctx.globalAlpha = 0.9 * chromeAlpha; ctx.textAlign = "center"; ctx.font = font(400, 12, false); for (const [text, dx, dy] of [["北", 0, -1], ["南", 0, 1], ["东", -1, 0], ["西", 1, 0]] as const) { @@ -237,7 +279,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt // Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer. ctx.textAlign = "left"; ctx.fillStyle = palette.inkSecondary; - ctx.globalAlpha = 1; + ctx.globalAlpha = 1 * textAlpha; ctx.font = font(600, 12, serif); // Tracked by hand (canvas letterSpacing is not in every Safari we support). let cursor = 48; diff --git a/frontend/src/lib/birth-sky/reveal.ts b/frontend/src/lib/birth-sky/reveal.ts index 0c92ec9e..e523a408 100644 --- a/frontend/src/lib/birth-sky/reveal.ts +++ b/frontend/src/lib/birth-sky/reveal.ts @@ -1,26 +1,45 @@ -import type { BirthSkyCover } from "./cover.ts"; +import type { BirthSkyOk } from "./contract.ts"; +import { fetchBirthSky } from "./cover.ts"; /** - * First-chart reveal timing (TASK-birth-sky-cover-20260928 T6). + * First-chart reveal timing (TASK-birth-sky-cover-20260928 T6, convergence + * since TASK-birth-sky-followup-20260928 F4). * - * After the reader's own birth place is saved, the cover gets one chance: if - * it is fetched and painted within `timeoutMs` it is shown, otherwise it is - * dropped silently — no error, no retry, and the onboarding flow never waits. + * After the reader's own birth place is saved, the reveal gets one chance: if + * its data is ready within `timeoutMs` it plays, otherwise it is dropped + * silently — no error, no retry, and the onboarding flow never waits. */ export const BIRTH_SKY_REVEAL_TIMEOUT_MS = 3_000; -export type BirthSkyRevealInput = Readonly<{ - load: (signal: AbortSignal) => Promise; - onReady: (cover: BirthSkyCover) => void; - /** A cover that arrived too late; the caller releases its object URL. */ - onDiscard?: (cover: BirthSkyCover) => void; +/** Everything the convergence overlay needs before its first frame. */ +export type BirthSkyRevealSky = Readonly<{ sky: BirthSkyOk; serif: boolean }>; + +/** Fetch the reader's own sky and wait (bounded) for the heading serif. Null on any failure. */ +export async function loadBirthSkyRevealSky(signal: AbortSignal): Promise { + try { + const sky = await fetchBirthSky("self", signal); + if (!sky || signal.aborted) return null; + const { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } = await import("./sentence.ts"); + const { waitForHeadingSerif } = await import("./render.ts"); + const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`); + return signal.aborted ? null : { sky, serif }; + } catch { + return null; + } +} + +export type BirthSkyRevealInput = Readonly<{ + load: (signal: AbortSignal) => Promise; + onReady: (value: T) => void; + /** A value that arrived too late; the caller releases whatever it holds. */ + onDiscard?: (value: T) => void; timeoutMs?: number; setTimer?: (callback: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; }>; /** Starts one reveal attempt; the returned function cancels it. */ -export function startBirthSkyReveal(input: BirthSkyRevealInput): () => void { +export function startBirthSkyReveal(input: BirthSkyRevealInput): () => void { const setTimer = input.setTimer ?? ((callback: () => void, ms: number) => setTimeout(callback, ms)); const clearTimer = input.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType)); const controller = new AbortController(); diff --git a/frontend/tests/profile-onboarding-birth-sky-reveal.test.ts b/frontend/tests/profile-onboarding-birth-sky-reveal.test.ts index 23db6e0d..38b407d8 100644 --- a/frontend/tests/profile-onboarding-birth-sky-reveal.test.ts +++ b/frontend/tests/profile-onboarding-birth-sky-reveal.test.ts @@ -2,20 +2,41 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import type { BirthSkyCover } from "../src/lib/birth-sky/cover.ts"; -import { BIRTH_SKY_REVEAL_TIMEOUT_MS, startBirthSkyReveal } from "../src/lib/birth-sky/reveal.ts"; +import { + BIRTH_SKY_CONVERGE_MS, + BIRTH_SKY_FADE_MS, + BIRTH_SKY_REVEAL_TOTAL_MS, + BIRTH_SKY_TEXT_MS, + birthSkyPositionsAt, + birthSkyRevealAt, + birthSkyScatter, + birthSkySeed, + birthSkyVisibleBodies, +} from "../src/lib/birth-sky/converge.ts"; +import { + BIRTH_SKY_EXPORT_HEIGHT, + BIRTH_SKY_EXPORT_WIDTH, + drawBirthSky, + interpolateSkyPoint, + type BirthSkyCanvas, +} from "../src/lib/birth-sky/draw.ts"; +import { BIRTH_SKY_REVEAL_TIMEOUT_MS, startBirthSkyReveal, type BirthSkyRevealSky } from "../src/lib/birth-sky/reveal.ts"; import { goldenSky } from "./birth-sky-test-support.ts"; -// 「那一刻的天空」 first-chart reveal (TASK-birth-sky-cover-20260928 T6). +// 「那一刻的天空」 first-chart reveal (TASK-birth-sky-cover-20260928 T6), +// convergence since TASK-birth-sky-followup-20260928 F4. const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const revealHook = readFileSync(new URL("../src/hooks/use-birth-sky-reveal.ts", import.meta.url), "utf8"); const revealComponent = readFileSync(new URL("../src/components/birth-sky/birth-sky-reveal.tsx", import.meta.url), "utf8"); +const converge = readFileSync(new URL("../src/components/birth-sky/birth-sky-converge.tsx", import.meta.url), "utf8"); +const convergeCss = readFileSync(new URL("../src/components/birth-sky/birth-sky-converge.css", import.meta.url), "utf8"); const dialog = readFileSync(new URL("../src/components/birth-sky/birth-sky-dialog.tsx", import.meta.url), "utf8"); const dialogCss = readFileSync(new URL("../src/components/birth-sky/birth-sky.css", import.meta.url), "utf8"); +const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8"); -const cover: BirthSkyCover = { key: "self:reveal", sky: goldenSky("day"), blob: new Blob([]), url: "blob:sky" }; +const cover: BirthSkyRevealSky = { sky: goldenSky("day"), serif: true }; function fakeClock() { let pending: { callback: () => void; ms: number } | null = null; @@ -34,10 +55,10 @@ function deferred() { return { promise, resolve, reject }; } -test("saved in time: the dialog opens with the painted cover; closing touches nothing else", async () => { +test("saved in time: the convergence starts once its data is ready; ending touches nothing else", async () => { const clock = fakeClock(); - const load = deferred(); - const shown: BirthSkyCover[] = []; + const load = deferred(); + const shown: BirthSkyRevealSky[] = []; startBirthSkyReveal({ load: () => load.promise, onReady: (item) => shown.push(item), ...clock }); assert.equal(clock.ms, BIRTH_SKY_REVEAL_TIMEOUT_MS); assert.equal(BIRTH_SKY_REVEAL_TIMEOUT_MS, 3_000); @@ -45,18 +66,18 @@ test("saved in time: the dialog opens with the painted cover; closing touches no await load.promise; await Promise.resolve(); assert.deepEqual(shown, [cover]); - assert.equal(clock.ms, null, "the timeout is cleared once the cover is shown"); - // Close only clears the reveal's own cover; the onboarding step is not touched. + assert.equal(clock.ms, null, "the timeout is cleared once the reveal starts"); + // Ending only clears the reveal's own state; the onboarding step is not touched. assert.match(page, //); const close = revealHook.slice(revealHook.indexOf("const close")); assert.doesNotMatch(close.slice(0, close.indexOf("}, []);")), /Onboarding|setOnboarding|setProfile|Journey/); }); -test("timeout: no dialog, the late cover is released, the request is aborted", async () => { +test("timeout: no overlay, the late data is dropped, the request is aborted", async () => { const clock = fakeClock(); - const load = deferred(); - const shown: BirthSkyCover[] = []; - const discarded: BirthSkyCover[] = []; + const load = deferred(); + const shown: BirthSkyRevealSky[] = []; + const discarded: BirthSkyRevealSky[] = []; let signal: AbortSignal | null = null; startBirthSkyReveal({ load: (next) => { signal = next; return load.promise; }, @@ -73,18 +94,20 @@ test("timeout: no dialog, the late cover is released, the request is aborted", a assert.deepEqual(discarded, [cover]); }); -test("failure: no dialog, no retry, no error surfaced", async () => { +test("failure: no overlay, no retry, no error surfaced", async () => { const clock = fakeClock(); let calls = 0; - const shown: BirthSkyCover[] = []; + const shown: BirthSkyRevealSky[] = []; startBirthSkyReveal({ load: async () => { calls += 1; throw new Error("offline"); }, onReady: (item) => shown.push(item), ...clock }); await new Promise((resolve) => setImmediate(resolve)); const nullCover = fakeClock(); - startBirthSkyReveal({ load: async () => { calls += 1; return null; }, onReady: (item) => shown.push(item), ...nullCover }); + startBirthSkyReveal({ load: async () => { calls += 1; return null; }, onReady: (item) => shown.push(item), ...nullCover }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls, 2); assert.deepEqual(shown, []); - assert.doesNotMatch(revealHook, /toast|setError|console\.(error|warn)/); + assert.doesNotMatch(revealHook, /setError|console\.(error|warn)/); + // The only toast is the hint in `close`, which runs after the overlay has played. + assert.equal(revealHook.match(/toast\(/g)?.length, 1); }); test("only the reader's own onboarding place save triggers it, after the flow has moved on", () => { @@ -106,15 +129,123 @@ test("only the reader's own onboarding place save triggers it, after the flow ha assert.doesNotMatch(readFileSync(new URL(path, import.meta.url), "utf8"), /BirthSkyReveal|useBirthSkyReveal|onOwnBirthPlaceSaved/); } assert.match(page, /onOwnBirthPlaceSaved: birthSkyReveal\.begin,/); - assert.match(revealHook, /loadBirthSkyCover\("self:reveal", "self", signal\)/); + assert.match(revealHook, /loadBirthSkyRevealSky\(signal\)/); }); -test("the reveal is the chart page's dialog plus 「继续」, faded in 600ms, still under reduced motion", () => { - assert.match(revealComponent, /dynamic\(\s*\(\) => import\("\.\/birth-sky-dialog"\)/); - assert.match(revealComponent, /reveal \/>/); - assert.match(dialog, /\{reveal \?