feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)
TASK-birth-sky-polish-20260928. - P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner. - P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」. - P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover. - P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence). 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:
co-authored by
Claude Opus 5.5
parent
1a4f77584b
commit
dec36bce01
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-28 — 「那一刻的天空」第三轮:只放图、印出生日期时刻城市、过场放慢、兑换弹窗改时机(待验收)
|
||||
|
||||
- 星盘页点「那一刻的天空」:去掉灰色面板和标题栏,压暗的页面上只放这张图,按屏幕大小完整显示;图下一个「保存图片」,右上角一个小 ×,点图外或按 Esc 也能关。
|
||||
- 图上事实句下面一行改印出生资料:「1994年5月18日 07:40 · 杭州」(日期、时刻、城市;城市拿不准就只到时刻)。采用了校正时间的末尾加「 · 校正后」。原来的「按你填写的时间」去掉。不印区县、经纬度、名字。星盘页图片、报告封面、首次建盘过场三处都印。
|
||||
- 首次建盘的群星过场从约 3 秒放慢到约 6.5 秒(汇聚 3 秒、文字停 2.5 秒、淡出 1 秒),仍可随时点一下跳过。
|
||||
- 「解锁完整咨询」兑换弹窗不再在首页一出现就弹(它和群星过场撞在一起,BUG-1080),改为第一次因点数不足发不出消息时弹;刚写的问题留在输入框里,兑换后点发送即可。之后再点数不足,照旧打开账户与点数。兑换弹窗说明句改为「问题还在输入框里。」。
|
||||
- Skill 版本不 bump(Skill 文本未改)。不改数据库结构。
|
||||
|
||||
## 2026-09-28 — 「那一刻的天空」追加:报告封面上线(待验收)
|
||||
|
||||
- 从这次起,新生成的报告会记下生成时用的是哪一套出生资料;打开报告时,只要这套资料和现在一致,正文最上方就有一张「那一刻的天空」封面(深色,和星盘页那张同一种画法)。打印或存 PDF 时封面换成浅色纸面版,单独占第一页。
|
||||
|
||||
@@ -14531,3 +14531,19 @@
|
||||
- 复发自:BUG-1078(同一根因,1078 只修了报告作用域)
|
||||
- 修复版本:`12aac87b` + 文档 `fa58df16`,随 `dc640931` 部署 staging。
|
||||
|
||||
|
||||
## BUG-1080 | 新账号首次建盘:看不到群星过场,只看到「解锁完整咨询」兑换弹窗
|
||||
|
||||
- 状态:investigating(修复已在分支 `codex/birth-sky-pacing-20260928`,待部署 staging 后真机确认;过场没出现的确切原因未在真实环境取证)
|
||||
- 首次发现 / 最近更新:2026-09-28 / 2026-09-28
|
||||
- 影响面:0 点数、无订阅的新账号,首页 onboarding 保存本人出生地之后的那一刻(`TASK-birth-sky-followup-20260928` F4 群星过场)。
|
||||
- 现象:产品在 staging 真机新建账号、走完 onboarding 后,没有看到群星汇聚过场,只看到「解锁完整咨询」兑换弹窗。
|
||||
- 触发条件:`saveOnboardingPlace` 成功 → `onOwnBirthPlaceSaved` 开始为过场取数(3 秒预算);同一刻首页变成 starter home,`page.tsx` 里「`starterHomeVisible` 且 0 点数且无订阅」的 effect 立即打开兑换弹窗,`modalOpen` 使 `SidebarInset` 变 `inert`,而过场 `<dialog>` 渲染在 `Home()` 里、也就是这棵 inert 子树内部。
|
||||
- 已排除 / 已确认:
|
||||
1. **「兑换弹窗盖住过场」在 Chrome 里不成立(已用无头 Chrome 151 最小复现排除)**:`inert` 容器里先放兑换弹层(portal 到 body、z-index 40、输入框已聚焦),再对容器内的 `<dialog>` 调 `showModal()`:不抛错、`:modal` 为真,截图里过场在最上层完整可见,`elementFromPoint` 命中过场画布,CDP 真实鼠标按下与 Esc 都送达过场(`pointerdown`、`cancel` 事件都触发)。与 BUG-968 不同:BUG-968 的账户弹窗是普通 `div`,不进顶层,所以会被 `inert` 锁住;`<dialog>.showModal()` 进顶层。复现页与截图:`scratchpad/polish/bug1080/repro.html`、`inert.png`、`plain.png`(会话 scratchpad,不进仓库)。Safari 未能验证。
|
||||
2. 仍可能的原因(未取证):① 3 秒预算内没拿齐数据(`/api/birth-sky` 冷启动、宋体切片等待最多 1.5 秒、过场 chunk 下载),过场被静默跳过,页面上只剩兑换弹窗;② Safari / iOS 对 inert 子树内顶层 `<dialog>` 的处理与 Chrome 不同;③ 产品当时的账号不是在首页 onboarding 里保存出生地(例如从设置或星盘档案补资料),本来就不触发。
|
||||
- 修复(`TASK-birth-sky-polish-20260928` P4,不依赖根因结论):① 删掉「进入 starter home 就弹兑换弹窗」的 effect;兑换弹窗改为本页第一次因点数不足发不出消息(前置余额检查或 402)时弹,之后照旧打开 billing(`lib/redeem-paywall-once.ts` + `useConsultationRun`),草稿留在输入框。建盘后首页不再有任何自动弹窗与过场抢位置。② 过场 `<dialog>` 改为 `createPortal(…, document.body)`,挂在 `SidebarInset` 之外,与账户弹窗、兑换弹窗同一做法,任何浏览器都不再受 `inert` 影响。③ 过场放慢到约 6.5 秒(产品同批要求)。
|
||||
- 验证:`frontend/tests/birth-sky-polish-20260928.test.ts`(兑换弹窗只由被拒的发送打开、第一次兑换弹窗第二次 billing、UI 预览不弹、402 后草稿退回、过场 portal 到 body);`membership-page.test.ts` 改写的时机断言;全量前端测试失败清单与基线逐条相同。无头 Chrome 最小复现见上。
|
||||
- 防复发:首页不得在「本人刚保存出生地」这一刻自动弹出任何弹窗;顶层 `<dialog>` 也要 portal 到 `SidebarInset` 之外;过场是否真的放了,部署后按 `docs/testing/birth-sky-cover-checklist.md` 新账号条目真机走一遍(若仍看不到过场,优先查 3 秒预算内 `/api/birth-sky` 的耗时)。
|
||||
- 相关记录:BUG-968(`SidebarInset` inert 与 portal 约定)、BUG-434(兑换弹窗样式);`TASK-birth-sky-followup-20260928` F4、`TASK-birth-sky-polish-20260928` P4。
|
||||
- 修复版本:分支 `codex/birth-sky-pacing-20260928`(本条所在提交),未部署。
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# PROGRESS · 天空封面第三轮(2026-09-28)
|
||||
|
||||
任务书:`docs/tasks/TASK-birth-sky-polish-20260928.md`。产品授权直接执行(Claude 子代理)。
|
||||
|
||||
- 基线:`origin/staging` = `84df1299`(任务书提交,纯文档);代码基线 `be9e4dbe`(天空封面五个提交 + 验收文档,已部署 staging)。分支建自 `be9e4dbe` 后 rebase 到 `84df1299`;提交前 `origin/staging` 又前进到 `1a4f7758`(纯文档:`TASK-self-edit-avatar-menu-20260928`,它写明要等本单推上 staging 后再开工改 `page.tsx`),已 rebase 上去,`docs/tasks/README.md` 索引冲突按两行都保留解决。
|
||||
- 分支 / 工作树:`codex/birth-sky-pacing-20260928` / `.worktrees/birth-sky-pacing-20260928`(本地提交,**未推送**,由 Claude 验收后推)。
|
||||
- 环境:Node v22.14.0(`/exec-daemon/node`);无 `.venv`,Python 用系统 `python3`;无 Docker;无头 `google-chrome` 151。
|
||||
- BUG 编号:开工时最大 BUG-1079;本单记 BUG-1080。
|
||||
- 同日其它改 `page.tsx` 的分支:开工时未发现。
|
||||
|
||||
## 结论
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| P1 星盘页弹层只留图 | 完成 |
|
||||
| P2 封面印「日期 时刻 · 城市」 | 完成(BFF 只下发 `caption` 字符串) |
|
||||
| P3 过场放慢 | 完成(3 + 2.5 + 1 秒) |
|
||||
| P4 兑换弹窗改时机 + 过场 portal | 完成;BUG-1080 **investigating**(Chrome 下「弹窗盖住过场」已排除,确切原因待真机) |
|
||||
| P5 文档 | 完成(DESIGN §17 与弹窗 Placement 条、VOICE、CHANGELOG、BUG_HISTORY、真机清单 31–42、任务索引) |
|
||||
| 让步 | 未用:兑换弹窗文案已改;海外城市有保守规则(不是一律不带) |
|
||||
|
||||
## P1 · 弹层只留图
|
||||
|
||||
- `components/birth-sky/birth-sky-dialog.tsx`:删掉 `<header>`(标题「那一刻的天空」+「关闭」文字按钮)与面板 / 框 / 页脚包裹;原生 `<dialog>` 只含右上角图标 ×(`aria-label="关闭"`,lucide `X`)、`<img>`、「保存图片」一行(保存失败提示仍在它上方)。弹层无障碍名称改为 `aria-label="那一刻的天空"`。点 dialog 本身(图和按钮以外)、Esc、× 关闭。
|
||||
- `birth-sky.css` 重写:dialog 透明、铺满视口、flex 纵向居中;`::backdrop` = `color-mix(in srgb, #1a1a19 90%, transparent)`(night palette 页底色);图 3:4,`max-width: 100%`、`max-height: calc(100dvh − 44px − 3 × --space-4 − 安全区)`;「保存图片」与 × 用 night 墨色 `#f2f0ea`(细描边 / 透明底,悬停 10% 底),均 ≥ 44px。删除 `.birth-sky-panel` / `-header` / `-frame` / `-footer` 与 ≥860px 桌面面板变体(全仓无其它引用)。
|
||||
- 样张(无头 Chrome,真实 `BirthSkyDialog` 组件 + 真实 `birth-sky.css`,样稿虚构资料 04:50 晨昏图,宋体切片以 data URL 内联;背景是示意页):
|
||||
- `/tmp/claude-1000/-workspace-Jyotisha/fb88e01e-1ced-4bcc-92fe-44c5bdcc878e/scratchpad/polish/dialog/dialog-390x844.png`(DPR 2;图 358×477 @ y153,按钮 y647)
|
||||
- 同目录 `dialog-1280x900.png`(图 606×808,按钮 y840)
|
||||
- 同目录 `dialog-1280x600.png`(图 381×508,按钮 y540)
|
||||
- 三张图都完整可见,图下没有空白面板。
|
||||
|
||||
## P2 · caption
|
||||
|
||||
- 新文件 `lib/birth-sky/caption.ts`:`birthSkyCity(label)` 与 `birthSkyCaption({ date, time, placeLabel })`。规则(写在函数注释里,各有测试):
|
||||
1. 直辖市与港澳(`北京市 · 朝阳区`、`香港特别行政区`)→ `北京`、`香港`;
|
||||
2. 中国 `省 · 市 · 区` → 第二段以「市」结尾时取它、去掉「市」(`河北省 · 邯郸市 · 峰峰矿区` → `邯郸`);盟 / 地区 / 自治州 / 县 / 林区、只有省(含台湾省)→ 不带城市;
|
||||
3. 海外世界表标签 `Country · Region · City`:恰好三段且不含汉字 → 第三段(英文原名);两段(只到州 / 省)→ 不带;
|
||||
4. 其它(空、一段、自由文本如 `浙江省杭州市`、`Hangzhou, Zhejiang, China`)→ 不带。
|
||||
日期 `1994年5月18日`(年月日不补零),时刻 24 小时制补零;格式不对返回 null。
|
||||
- `contract.ts`:`birthSkyOkSchema` 加必填 `caption: string(1..64)`;`birthSkyFromEngine(payload, timeBasis, caption)`。
|
||||
- `service-core.ts`:用服务端 `prepareChartViewProfile` 的 `date` / `time`(即实际算天空用的时刻,采用校正时是校正后的时刻)/ `placeLabel` 拼 caption;拼不出(理论上不会,profile 已校验)按 503 处理。响应里仍然没有任何日期、时刻、经纬度、时区、地名、名字字段。
|
||||
- `sentence.ts`:删 `BIRTH_SKY_REPORTED_FOOTNOTE` / `birthSkyFootnote`,加 `BIRTH_SKY_ADOPTED_SUFFIX = " · 校正后"` 与 `birthSkyCaptionLine(sky)`。
|
||||
- `draw.ts`:删 `footnote` 选项(全仓无调用方传它);事实句下 y = 712、12.5px、`inkSecondary` 画 `birthSkyCaptionLine(sky)`(位置、字号、颜色与原 footnote 相同)。三处(星盘页图、报告封面、首次过场)共用,自动都有。
|
||||
- golden:引擎输出不变(caption 是 BFF 层拼的),`frontend/tests/fixtures/birth-sky-golden.json` 无需重新生成;`tests/birth-sky-test-support.ts` 用夹具的 `times` + 虚构地点 `浙江省 · 杭州市 · 西湖区` 经同一个 `birthSkyCaption` 生成 caption。
|
||||
- 海报(无头 Chrome,同一 `drawBirthSky`,含宋体):
|
||||
- `/tmp/claude-1000/-workspace-Jyotisha/fb88e01e-1ced-4bcc-92fe-44c5bdcc878e/scratchpad/polish/posters/poster-day-night-reported.png`(「1994年5月18日 07:40 · 杭州」)
|
||||
- 同目录 `poster-night-night-adopted.png`(「1994年5月18日 00:00 · 杭州 · 校正后」)
|
||||
- 同目录 `poster-twilight_evening-paper-reported.png`(paper palette,「1994年5月18日 19:10 · 杭州」)
|
||||
|
||||
## P3 · 放慢
|
||||
|
||||
- `converge.ts`:汇聚 1.5 → 3 s,文字 1 → 2.5 s,淡出 0.5 → 1 s(总 6.5 s);文字淡入 400 → 600 ms;整层前段淡入 200 → 400 ms;reduced-motion 淡入 400 → 600 ms(淡出随 `BIRTH_SKY_FADE_MS` 变 1 s)。3 秒取数预算 `BIRTH_SKY_REVEAL_TIMEOUT_MS` 不变。
|
||||
- 「t=0 散开、t=1 真实位置」与「停留帧 = 导出封面(逐调用)」两条测试原样保留且通过。
|
||||
- 四帧(黑体画,排除字体时序;night 与 day 各一组):`/tmp/claude-1000/-workspace-Jyotisha/fb88e01e-1ced-4bcc-92fe-44c5bdcc878e/scratchpad/polish/birth-sky-converge-night-{t0,t050,t100,hold}.png`(0 / 1500 / 3000 / 5400 ms)与 `birth-sky-converge-day-*.png`;**停留帧 PNG 与导出封面 PNG 逐字节相同**(night、day 都是 `true`,`frames/*-export.png` 对照)。
|
||||
|
||||
## P4 · 兑换弹窗时机、过场 portal、BUG-1080
|
||||
|
||||
### 无头 Chrome 最小复现(先复现再修)
|
||||
|
||||
- 页面 `scratchpad/polish/bug1080/repro.html`:`#inset`(模拟 `SidebarInset`)里放过场同款 `<dialog>`;先把兑换弹层 portal 到 body(`position: fixed; z-index: 40`,输入框已聚焦),再 `inset.inert = true`,300ms 后对 inert 容器里的 dialog `showModal()`。驱动 `scratchpad/polish/bug1080/cdp.mjs`(CDP:截图、真实鼠标按下、真实 Esc)。
|
||||
- 结果(`inert` 与对照 `plain` 两种一致):`showModal` 不抛错、`:modal` 为真;截图 `bug1080/inert.png` 里过场在最上层完整可见,兑换弹层看不到;`elementFromPoint(中心)` = 过场画布;鼠标按下触发 dialog 的 `pointerdown`,Esc 触发 `cancel`。
|
||||
- 结论:**Chrome 151 下「兑换弹窗盖住过场 / inert 让过场点不动」不成立**(与 BUG-968 不同:那里的账户弹窗是普通 div,不进顶层)。首页代码里的时序是:保存出生地 → 首页变 starter home 并等开场语打完 → 兑换 effect 立即打开;过场要在 3 秒内拿齐 `/api/birth-sky`、宋体(最多 1.5 秒)、过场 chunk 才开始。仍可能的原因:3 秒预算内没拿齐被静默跳过(页面上只剩兑换弹窗)、Safari 行为不同、或当时不是在首页 onboarding 保存出生地。写成 BUG-1080 `investigating`。
|
||||
|
||||
### 修复
|
||||
|
||||
1. `page.tsx`:删掉「`starterHomeVisible` 且 0 点数且无订阅就 `setOnboardingPaywallOpen(true)`」的 effect(−12 行);把现有的 `onboardingPaywallShown` ref 与 `setOnboardingPaywallOpen` 传给 `useConsultationRun`(+1 行)。`page.tsx` 1375 → 1364 行;`Home()` 的 `useState` / `useRef` 数不变(未新增)。
|
||||
2. 新文件 `lib/redeem-paywall-once.ts` `tryOpenRedeemPaywall({ uiPreview, shown, open })`:本页第一次返回 true 并打开兑换弹窗,之后返回 false。`useConsultationRun` 里包成 `openRedeemPaywallOnce()`,两处点数不足:
|
||||
- 前置余额检查 `if (account.credits <= 0 && !account.activeSubscription)`:`if (!openRedeemPaywallOnce()) openAccountDialog("billing", …)`,仍在清空草稿之前 return,问题留在输入框;
|
||||
- 402(`rejectRun`,HTTP 体与流内 `request_rejected` 共用):`if (status === 402 && !openRedeemPaywallOnce()) openAccountDialog("billing", …)`;既有 rollback 把草稿退回输入框。
|
||||
3. `birth-sky-converge.tsx`:`createPortal(<dialog …/>, document.body)`,挂到 `SidebarInset` 之外。
|
||||
4. 兑换弹窗文案(`onboarding-redeem-paywall.tsx`,已收进 VOICE):「入门问题已经准备好。填写兑换码,解锁后续咨询、生时校正与完整解读。」→「**问题还在输入框里。**填写兑换码,点数到账就能发送,生时校正与完整解读也能用。」;到账后「点数已到账,可以继续选择问题。」→「点数已到账,回到输入框点发送就行。」。**请 Claude / 产品验收这两句。**
|
||||
- 生时校正对话的 402(`lib/rectification-chat-turn-run.ts`)未动,有源码断言锁住。
|
||||
|
||||
## 改动的既有断言
|
||||
|
||||
| 测试(文件) | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `birth-sky-route.test.ts` 「a complete profile returns the contract and leaks no birth data」→ 改名「…birth data leaves only as the caption line」 | 响应 JSON 不含 date/time/place 等键,也不含 1994、07:40、30.27、120.15、虚构、Asia/Shanghai | 键断言不变;`caption` = 「1994年5月18日 07:40 · 杭州」;任何位置都不含 30.27、120.15、虚构、Asia/Shanghai、西湖区、浙江、UTC;把 `caption` 置空后其余部分不含 1994、07:40、杭州。夹具地点从「虚构地点」改为 `浙江省 · 杭州市 · 西湖区` | P2 产品决定印日期时刻城市;BFF 只下发 caption 字符串 |
|
||||
| `birth-sky-draw.test.ts` 「no drawn text carries a date, a time, coordinates, a place or a name」→ 改名「only the caption line carries birth data…」 | 所有画出的文字不含年份、时刻、「杭州」、年 / 月 / 日 | 只有 caption 那一行含日期时刻城市;其它文字仍不含;任何文字不含经纬度、UTC、区县、省、名字 | 同上 |
|
||||
| `birth-sky-draw.test.ts` 「「按你填写的时间」 appears for a reported time and not for an adopted one」→ 由「the caption replaces the footnote; an adopted time adds 「· 校正后」」取代 | reported 有「按你填写的时间」,adopted 没有 | y = 712 处 reported 为 caption、adopted 为 caption + 「 · 校正后」;两者都没有「按你填写的时间」;paper palette 的 caption 用 `inkSecondary` | P2:caption 替换 footnote 位置 |
|
||||
| `birth-sky-sentence.test.ts` 「the reported-time footnote appears only when the time was not adopted」→ 由「the caption line appends 「· 校正后」 only for an adopted time」取代 | `birthSkyFootnote` reported → 「按你填写的时间」,adopted → null | `birthSkyCaptionLine` reported → caption,adopted → caption + 「 · 校正后」 | 同上 |
|
||||
| `profile-onboarding-birth-sky-reveal.test.ts` 「timeline: converge ~1.5 s, words ~1 s, fade ~0.5 s…」→ 改名「timeline: converge ~3 s, words ~2.5 s, fade ~1 s…」 | 1_500 / 1_000 / 500,总 3_000;检查点 750、1_500、2_400、2_750、3_000 ms;reduced 400ms 淡入 | 3_000 / 2_500 / 1_000,总 6_500;检查点 1_500、3_000、3_300、3_600、5_400、6_000、6_500 ms;新增整层 400ms 与文字 600ms 淡入断言;reduced 600ms 淡入 | P3 产品要求放慢 |
|
||||
| `membership-page.test.ts` 「finished starter questions open the redeem paywall only for accounts without access」→ 由「the redeem paywall opens on the first send refused for credits, not when the starter home appears」取代 | page.tsx 有 `!starterHomeVisible` / `credits > 0` / `activeSubscription active` 守卫的 effect 调 `setOnboardingPaywallOpen(true)`;文案含「入门问题已经准备好」 | page.tsx 不再调 `setOnboardingPaywallOpen(true)`,只把 ref / setter 传给 hook;hook 用 `tryOpenRedeemPaywall`;两处点数不足各 1 处「先兑换弹窗、否则 billing」;文案「问题还在输入框里。」且不再有「入门问题已经准备好」;其余(兑换接口、按钮、billing 回调)断言原样保留 | P4 / BUG-1080 |
|
||||
| `chat-navigation-a11y-contract.test.ts`(测试名不变) | `/if \(status === 402\) openAccountDialog\("billing", …\)/` | `/if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", …\)/`;「共 2 处 billing 调用」计数不变 | P4 |
|
||||
| `consult-first-frame-and-pacing-20260928.test.tsx`(测试名不变) | `rejectRun` 里 `if (status === 402) openAccountDialog(…)` | `if (status === 402 && !openRedeemPaywallOnce()) openAccountDialog(…)`;计数 2 不变 | P4 |
|
||||
|
||||
测试名对基线 `be9e4dbe`:**消失 6 条**(上表 6 条改名 / 取代,均为本系列自己的测试或本单改时机的那条,逐条有替代),新增 18 条。任务书验证项写「不得有测试名消失」,这 6 条是按新决策改名,旧名描述的行为已被产品推翻,保留旧名会与断言内容相反,所以没有保留,请 Claude 定。
|
||||
|
||||
## 验证
|
||||
|
||||
基线与交付在同一台机器、同一命令各跑一次(基线用临时 detached worktree `be9e4dbe`,`node_modules` 硬链接,用完已删)。
|
||||
|
||||
| 项 | 基线 `be9e4dbe` | 交付 |
|
||||
| --- | --- | --- |
|
||||
| `tsc --noEmit` | 0 错 | 0 错 |
|
||||
| `npm run lint` | 0 error / 127 warning | 0 error / 127 warning(逐条同名;中途多出的 1 条 `_caption` 未使用已修掉) |
|
||||
| `npm test` | tests 4310 · pass 4255 · fail 24 · cancelled 0 · skipped 31 | tests 4322 · pass 4267 · fail 24 · cancelled 0 · skipped 31 |
|
||||
| 失败清单 | 24 条(Docker / PostgreSQL 数据库与部署套件) | 与基线逐条同名 |
|
||||
| 测试名对比 | — | 消失 6(见「改动的既有断言」,均改名 / 取代),新增 18 |
|
||||
| `python3 -m pytest tests/test_birth_sky.py tests/test_api_server_growth_contract.py tests/test_repo_privacy_markers.py` | — | 全部通过 |
|
||||
| `python3 scripts/run_quality_gate.py --profile quick`(Node 22 在 PATH) | — | Python 步骤全过(pytest 997 passed / 1 skipped);末步 `npm test` 24 条失败与基线逐条同名 → 退出码 1(无 Docker) |
|
||||
| `rm -rf .next && next build` | `/` ○ Static | `/` ○ Static;路由表与基线逐行相同 |
|
||||
| `JyotishAPIHandler` / `jyotish_api_server.py` | — | 未改 |
|
||||
| `page.tsx` 行数 / `Home()` 的 `useState` + `useRef` | 1375 / — | 1364 / 未新增 |
|
||||
|
||||
### 首屏 gzip(同一脚本 `scratchpad/polish/gzip.sh`:预渲染 `/` 的 `index.html` 里引用的全部 `/_next/static/*.js|css`,逐个 gzip -9 求和;两边都是干净全量构建)
|
||||
|
||||
| | 基线 | 交付 | 变化 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| JS | 650,883 B | 650,972 B | +89 B(+0.01%) |
|
||||
| CSS | 66,126 B | 66,126 B | 0 |
|
||||
|
||||
JS +89 B 是 `useConsultationRun` 引入的 `tryOpenRedeemPaywall` 减去 `page.tsx` 删掉的 effect。caption、弹层、过场改动都在懒 chunk。
|
||||
|
||||
## 偏离任务书(请 Claude 定)
|
||||
|
||||
1. **兑换弹窗不是「以服务端 402 为准」的唯一入口**:`useConsultationRun` 在发送前本来就有前置余额检查(`credits <= 0 && !activeSubscription` 直接拦下、打开 billing),0 点账号连「你好」都发不出去,根本到不了服务端。本单没有删这个前置检查(改计费行为不在任务书范围),而是让它和 402 走同一个「本页第一次先弹兑换弹窗」。结果:0 点账号发**任何**第一条消息(含闲聊)都弹兑换弹窗,而不是只有付费消息。任务书决策 4 里「免费闲聊轮照常免费、不会被弹窗拦住」对 0 点账号并不成立(改之前就不成立)。真机清单 34 条请产品看实际表现。
|
||||
2. **golden 未重新生成**:引擎输出没变,caption 在 BFF 层拼,夹具只加了测试侧的虚构地点。
|
||||
3. **测试名消失 6 条**(见上)。
|
||||
4. **弹层无障碍名称用 `aria-label`**(原来是 `aria-labelledby` 指向标题,标题删了)。
|
||||
5. **海外城市不是一律不带**:世界表的三段标签取第三段英文原名。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无登录态与真机:弹层、caption、过场节奏、兑换弹窗新时机只在单测、源码合同、无头 Chrome 样张上验证;真机清单 `docs/testing/birth-sky-cover-checklist.md` 新增 31–42 条。
|
||||
- BUG-1080 的真实原因需要部署后在真机上重走新账号流程;Safari 未能验证。
|
||||
- 无 Docker:数据库 / 部署套件 24 条失败与基线逐条同名;quick 门禁最后一步因此退出码 1。
|
||||
- 未推送、未部署。
|
||||
@@ -250,7 +250,7 @@
|
||||
| `TASK-site-button-contrast-20260928.md` | `PROGRESS-site-button-contrast-20260928.md` | **全站实心按钮配色与禁用态统一**:层外 `button { color: inherit }` 压掉 Tailwind 文字色(61 个 `<Button>` 浅 3.33 / 深 2.57),`.button-primary` 30 个浅色 3.14,全部禁用态 45% 透明;产品选 A:全站用报告页配色(浅 4.85 / 深 5.77),推翻 DESIGN「已知缺口」;禁用改灰;删 BUG-1078 局部覆盖(BUG-1079) | 已验收、已部署 staging `dc640931`(真机欠) | `12aac87b` + `fa58df16`;BUG-1079 |
|
||||
| `TASK-birth-sky-cover-20260928.md` | `PROGRESS-birth-sky-cover-20260928.md` | **「那一刻的天空」封面**:出生那一刻头顶的真实天空做成 3:4 封面(产品从 A 年轮 / B 一笔画 / C 天空三版样稿中选 C,样稿 `assets/birth-sky-cover-sample-20260928.html`);星盘页按钮 + 保存图片、报告封面(须对得上报告出生资料)、首次建盘揭幕;不印出生资料、句库只描述天空、无新依赖 | 已验收(Claude 2026-09-28),随分支五个提交推 staging;T5 由追加单解除 | 新功能,不开 BUG |
|
||||
| `TASK-birth-sky-followup-20260928.md` | `PROGRESS-birth-sky-cover-20260928.md`(追加单) | **天空封面追加**:报告封面按生成时出生资料指纹绑定(方案 A,旧报告永不显示);首次建盘揭幕改群星汇聚过场(无按钮、3 秒预算、可点跳过、减弱动态只淡入淡出) | 已验收(Claude 2026-09-28),已推 staging | 新功能,不开 BUG |
|
||||
| `TASK-birth-sky-polish-20260928.md` | `PROGRESS-birth-sky-polish-20260928.md` | **天空封面第三轮**:星盘页弹层只留图;封面印「日期 时刻 · 城市」(推翻「不印出生资料」);过场放慢到约 6.5 秒;兑换弹窗改到首条付费消息被拒(402)时再弹;BUG-1080 过场被兑换弹窗挡住 | 待领取(分支 `codex/birth-sky-pacing-20260928` 已建) | BUG-1080 |
|
||||
| `TASK-birth-sky-polish-20260928.md` | `PROGRESS-birth-sky-polish-20260928.md` | **天空封面第三轮**:星盘页弹层只留图;封面印「日期 时刻 · 城市」(推翻「不印出生资料」);过场放慢到约 6.5 秒;兑换弹窗改到首条付费消息被拒(402)时再弹;BUG-1080 过场被兑换弹窗挡住 | 已实现待验收(分支 `codex/birth-sky-pacing-20260928`,未推送);BUG-1080 investigating(Chrome 下「弹窗盖住过场」已排除,修复不依赖根因) | BUG-1080 |
|
||||
| `TASK-self-edit-avatar-menu-20260928.md` | `PROGRESS-self-edit-avatar-menu-20260928.md` | **本人可编辑 + 各页头像菜单 + 报告页去说明**:`/people` 本人可编辑出生资料(BUG-1081,`ab6c55f8` 漏掉);次级页头像弹同一账户菜单、弹窗项跳 `/?account=`;我的报告删顶部说明与统计 | 待领取(须等 birth-sky-polish 推 staging 后串行) | BUG-1081 起 |
|
||||
| `TASK-serif-headings-20260928.md` | `PROGRESS-serif-headings-20260928.md` | **全站标题改用自托管宋体、正文保持黑体**:产品推翻 BUG-737「CJK 不用衬线」结论(保留「声明的字体必须可加载」「不落系统宋体」两条);Noto Serif SC SemiBold 按通用规范汉字表 6500 字 unicode-range 切片自托管(改名 Jyotisha Serif SC),swap 不 preload;首页宋体流量 ≤300 KB;先于天空封面单 | 已实现待验收(分支 `codex/serif-headings-20260928`,未推送) | 不开新 BUG;BUG-737 追加说明 |
|
||||
| `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `<details>` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — |
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
共同判断标准(每台设备都一样):
|
||||
|
||||
1. 图是深色卡纸,天顶在圆心、**东在左**、北在上;地平线以上有行星(中文名)和亮星,太阳是赭色空心圆加光晕。
|
||||
2. 图上**没有**姓名、日期、时刻、经纬度、地名。
|
||||
2. ~~图上**没有**姓名、日期、时刻、经纬度、地名。~~(第三轮作废)→ 事实句下一行是「日期 时刻 · 城市」,例「1994年5月18日 07:40 · 杭州」;**没有**姓名、区县、经纬度、时区。
|
||||
3. 主句是四句之一(「星星都在,只是还看不见。」「天快亮了,星星正一颗颗退场。」「天刚暗下来,星星正一颗颗出来。」「那一刻,头顶就是这片天。」),用宋体;下面一行事实句(如「太阳在东,月亮在脚下,火星正悬在东南」),最底「仅供自我探索,不替你做决定。」。
|
||||
4. 出生时间没做过校正(或没采用)时,事实句下多一行「按你填写的时间」;采用过校正时间的没有这一行。
|
||||
4. ~~出生时间没做过校正(或没采用)时,事实句下多一行「按你填写的时间」;采用过校正时间的没有这一行。~~(第三轮作废)→ 采用过校正时间的,出生资料一行末尾多「 · 校正后」;没采用的什么都不加,也不再有「按你填写的时间」。
|
||||
5. 按钮出现之前不闪任何占位、转圈或错误;弹层打开就是图,没有等待。
|
||||
|
||||
## 星盘页入口与保存
|
||||
@@ -58,3 +58,22 @@
|
||||
| 28 | 任一 | 网络很慢或断网时保存出生地(手机可开飞行模式后再关、或 DevTools 限速 Slow 3G) | 过场不出现,引导不卡住,没有转圈 | |
|
||||
| 29 | 任一 | 24 看完之后 | 首页下方轻提示一次「这片天空在星盘页可以保存。」,只出现一次;刷新首页不再出现,也不再放过场 | |
|
||||
| 30 | 任一 | 老账号平时打开首页 | 加载动画和以前一样,没有群星过场 | |
|
||||
|
||||
## 第三轮(`TASK-birth-sky-polish-20260928`,2026-09-28)
|
||||
|
||||
第三轮后 1、7 的期望改为:点开后**没有**灰色面板和标题栏,压暗的页面上只有图、图下「保存图片」、右上角一个小 ×;7 的「点『关闭』」改为「点 ×」。24 的时长改为约 6.5 秒(汇聚约 3 秒 → 文字停约 2.5 秒 → 淡出约 1 秒)。
|
||||
|
||||
| # | 设备 / 浏览器 | 操作 | 期望 | 结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 31 | iPhone Safari(新账号,0 点) | 走完首页引导,最后一步保存出生地 | 完整看到约 6.5 秒的群星过场;过场期间和结束后**都不弹**「解锁完整咨询」 | |
|
||||
| 32 | 同上 | 过场结束后,在输入框写一个正式问题并发送 | 弹「解锁完整咨询」,说明句是「问题还在输入框里。」;关掉后刚写的问题还在输入框里 | |
|
||||
| 33 | 同上 | 关掉兑换弹窗后再点一次发送 | 这次打开的是「账户与点数」,不是兑换弹窗 | |
|
||||
| 34 | 桌面 Chrome(0 点账号) | 发「你好」 | 同 32(0 点账号在前端余额检查就被拦下,闲聊也一样);记录实际表现 | |
|
||||
| 35 | 同上 | 在兑换弹窗里兑换一个有效码 | 显示到账,提示「点数已到账,回到输入框点发送就行。」;关掉后点发送,问题正常发出 | |
|
||||
| 36 | iPhone Safari | 星盘页点「那一刻的天空」 | 只有图和「保存图片」,没有灰色面板、没有图下大片空白;图完整显示,不用滚动 | |
|
||||
| 37 | 桌面 Chrome(窗口正常大小与压扁到约 600 高各一次) | 同 36 | 同 36;点图外空白处关闭 | |
|
||||
| 38 | 任一 | 看图底部出生资料一行 | 「日期 时刻 · 城市」,与自己的资料一致,不带区县 | |
|
||||
| 39 | 任一 | 用出生地是直辖市(如北京市某区)的人物打开 | 城市写「北京」 | |
|
||||
| 40 | 任一 | 用出生地在海外的人物打开 | 有城市时写城市名(英文原名);只选到州 / 省时只写到时刻 | |
|
||||
| 41 | 任一 | 用采用过校正时间的人物打开 | 末尾多「 · 校正后」,时刻是校正后的时刻 | |
|
||||
| 42 | 桌面 Chrome | 新生成一份报告并打印 PDF | 屏幕封面与打印封面都有出生资料一行 | |
|
||||
|
||||
+10
-8
@@ -677,6 +677,7 @@ page. Three parts now, in reading order:
|
||||
### Settings dialog
|
||||
|
||||
- **Placement:** the overlay and the onboarding paywall portal to `document.body`. `SidebarInset` stays `inert` while a dialog is open (the BUG-744~746 focus contract); the dialog itself must not sit inside that subtree (BUG-968). Closing still uses the existing overlay click, the header button, and the window-level Escape listener.
|
||||
- **Redeem paywall timing (2026-09-28, TASK-birth-sky-polish-20260928 P4):** the 「解锁完整咨询」 paywall no longer opens when the starter home appears. The first send on the page refused for lack of credits (the pre-flight balance check or a 402) opens it, and the typed question stays in the composer; every later refusal opens the billing dialog as before. Rectification's own 402 path is unchanged.
|
||||
- **Structure:** one fixed chrome for three panes — 个人资料, 账户与点数, 通用设置. The chart archive is no longer a pane; `?settings=chart` leaves for `/people`. Left nav is 200px, has a hairline boundary, and does not scroll; the title bar stays put; only the right-hand content pane scrolls. The pane menu uses icon + label rows without a trailing chevron because these controls switch content inside the same dialog. Logout stays a separate 400px confirmation.
|
||||
- **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. The right content pane uses 32px horizontal and bottom inset on desktop. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top and 16px content inset.
|
||||
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, no trailing navigation chevron, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
|
||||
@@ -1131,21 +1132,21 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。首屏未到时 `SecondaryPageShell` 写「这一天的星历还没拿到。」,居中在已撑满的内容区里。未登录是静态「请先登录」加去登录,不是转圈。同一会话再次进入直接用模块缓存。
|
||||
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。当日总评只来自上面的固定规则。不另印「这是天象本身」这类边界说明。
|
||||
|
||||
## 17. 那一刻的天空(2026-09-28,TASK-birth-sky-cover-20260928)
|
||||
## 17. 那一刻的天空(2026-09-28,TASK-birth-sky-cover-20260928;第三轮 TASK-birth-sky-polish-20260928)
|
||||
|
||||
出生地、出生那一刻头顶的真实天空,画成一张 3:4 封面。不讲运势,不贴标签。产品从三版样稿(`docs/tasks/assets/birth-sky-cover-sample-20260928.html`)里选了 C;A 年轮、B 一笔画不做。
|
||||
|
||||
- **投影与方位:** 天顶在圆心,r = (90 − 高度) / 90 × R;北在上,**东在左**(仰望天空,不是俯视地图)。圆是地平线,另有 30° / 60° 两道虚线高度圈;东南西北四个字在圆外。方位角一律「自正北起、向东为正」(Swiss Ephemeris 的 `azalt` 自正南起算,服务端已换算)。高度是几何高度,不加大气折射。
|
||||
- **画什么:** 地平线以上的太阳、月亮、水金火木土;太阳是赭色空心圆加光晕,其余是小圆点加中文名。V ≤ 4.0 的亮星(耶鲁亮星表,公有领域),点的大小按星等。北斗、仙后座、猎户座三组连线,只连两端都在地平线上的那一段。罗睺、计都不画;月亮在地平线下不画,只进事实句。
|
||||
- **版面(600 × 800 设计格,导出 1080 × 1440):** 顶部小字「JYOTISHA · 那 一 刻」→ 细线 → 主句 → 天空圆 → 细线 → 事实句 → 需要时「按你填写的时间」(出生时间不是已采用的校正时间时)→ 最底「仅供自我探索,不替你做决定。」。主句与顶部小字用 Jyotisha Serif SC(§3);画布绘制前 `document.fonts.load` 最多等 1.5 秒,超时就用黑体画,不出等待态。事实句、方位字、行星名、底部说明用黑体栈。
|
||||
- **版面(600 × 800 设计格,导出 1080 × 1440):** 顶部小字「JYOTISHA · 那 一 刻」→ 细线 → 主句 → 天空圆 → 细线 → 事实句 → 出生资料一行(caption,见下)→ 最底「仅供自我探索,不替你做决定。」。主句与顶部小字用 Jyotisha Serif SC(§3);画布绘制前 `document.fonts.load` 最多等 1.5 秒,超时就用黑体画,不出等待态。事实句、caption、方位字、行星名、底部说明用黑体栈。
|
||||
- **两套 palette,只用现有 token:** `night`(屏幕)= 深色主题的页底 `#1a1a19`、墨色 `#f2f0ea`、二三级墨色、深色细线、深色 action `#d78064` 画太阳;`paper`(报告打印)= `--report-paper`、`--color-ink`、`--report-rule`、`--report-accent` 画太阳。不新增品牌色。
|
||||
- **不印出生资料:** 图上没有姓名、日期、时刻、经纬度、地名;接口响应里也没有。天空本身会透露大致时段,产品已接受。文件名固定 `jyotisha-sky.png`。
|
||||
- **印日期、时刻、城市(2026-09-28 产品推翻「不印出生资料」):** 事实句下一行(原「按你填写的时间」的位置,字号颜色不变)印 `1994年5月18日 07:40 · 杭州`:出生地当地日期、24 小时制时刻、城市。城市只取到市(去「市」字,直辖市与港澳用本名),拿不准(只有省、盟 / 地区 / 自治州、自由文本)就只印到时刻,不猜。采用了校正时间的末尾加「 · 校正后」,按填写时间的不加说明。不印区县、经纬度、时区、名字。这一行由服务端拼好,接口只下发这一个 `caption` 字符串,不下发任何出生资料字段。星盘页图片、报告封面、首次建盘过场三处都印。文件名固定 `jyotisha-sky.png`。
|
||||
- **一个画法:** 预览、保存、打印共用 `lib/birth-sky/draw.ts` 的 `drawBirthSky`(Canvas 2D 纯函数)。预览是这张 PNG 的 `<img>`,手机长按即可存图;不走 SVG 转图片(网页字体会丢)。
|
||||
|
||||
### 位置一:星盘页顶栏按钮
|
||||
|
||||
- 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(`<Button variant="outline" size="sm">`,顶栏里按 `.chat-header-actions button` 收成 32px + 命中区外扩)。预取失败、资料不全、切人物时都不显示按钮,不占位、不报错。切换人物或改出生资料(指纹变)就按新人物重新预取。家人的盘也能生成。
|
||||
- 点按钮打开原生 `<dialog>`:窄屏占满(`100dvh`),≥860px 居中 `min(520px, 92vw)`、最高 92vh,框与报告「分块导出」抽屉同一套描边、圆角、阴影与遮罩。内容只有标题「那一刻的天空」+「关闭」、3:4 等比缩放的图、底部「保存图片」。Esc、点遮罩、关闭都能关,焦点回到按钮。所有按钮 ≥ 44px。
|
||||
- 点按钮打开原生 `<dialog>`,**只放图**(第三轮 P1,原来的灰色面板、标题栏、「关闭」文字按钮都删了):dialog 本身透明、铺满视口,`::backdrop` 是 `night` 页底色 90% 不透明;图 3:4,按视口取最大(宽不超过视口减 32px,高给下方「保存图片」一行留位),圆角 `--radius-md`;图下方居中「保存图片」(night 墨色细描边,透明底);右上角一个只有图标的 ×(读屏名「关闭」)。点图以外的地方、按 Esc、点 × 都能关,焦点回到按钮。所有按钮 ≥ 44px。窄屏和桌面同一套,不再有桌面居中面板。
|
||||
- 「保存图片」:浏览器能分享文件时(iOS Safari)调 `navigator.share({ files })`,系统面板里有「存储图像」;否则 Blob + `a.download`。用户取消分享不算错误、不提示。
|
||||
- 弹层、画布与导出代码都在懒加载 chunk 里(`next/dynamic` + 动态 `import()`),首页 `/` 不带它们。按钮本身是星盘页里的一个普通 `<Button>`(只在数据就绪时渲染)。
|
||||
- 等待:没有等待态。按钮出现时图已就绪;弹层打开就是图。
|
||||
@@ -1164,10 +1165,11 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
- **触发不变:** 只有一处——首页 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,露出**原本的下一步**(问候 / 首页,或出生时间校正引导),下一步不因它改变。
|
||||
- **时序(约 6.5 秒,第三轮放慢):** ① 汇聚约 3 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 400ms 淡入,避免页面一下变黑。② 显现文字约 2.5 秒:顶部小字、细线、主句、事实句、caption、底部说明在前 600ms 淡入,然后停留。③ 淡出约 1 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页,或出生时间校正引导),下一步不因它改变。
|
||||
- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数;停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。
|
||||
- **全屏、无按钮:** 原生 `<dialog>`(顶层,0 点数账户的兑换弹窗若同时出现,过场在它之上)铺满视口,底色是 `night` 的页底色;画布按视口取最大的 3:4 区域、乘 `devicePixelRatio` 绘制,`requestAnimationFrame` 驱动,卸载时取消。点击、触摸(`pointerdown`)或 Esc 立刻结束,直接进入下一步。
|
||||
- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚:成品画面整体淡入(400ms)、停留、同一时刻淡出,总长同样约 3 秒。
|
||||
- **全屏、无按钮:** 原生 `<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 秒。
|
||||
- **与兑换弹窗不再同时出现:** 0 点数账户的「解锁完整咨询」兑换弹窗不再在首页出现时自动弹出,改为本页第一次因点数不足发不出消息时弹(见 §账户弹窗「Placement」条),所以建盘后首页只有过场。
|
||||
- **结束后的提示:** 过场正常结束或被点掉后,首页用已有的 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` 样式与「继续」按钮,其余不动。
|
||||
- 代码:时序与插值是 `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` 样式与「继续」按钮。
|
||||
|
||||
@@ -155,11 +155,11 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
事实句用「,」连接,不带句号:白天先写「太阳在{八方位}」;然后月亮——在地平线上写「月亮在{八方位}」,在地平线下写「月亮在脚下」;然后除日月外最高的一颗可见行星「{行星}正悬在{八方位}」(没有就不写)。八方位是北、东北、东、东南、南、西南、西、西北。例:「太阳在东,月亮在脚下,火星正悬在东南」。
|
||||
|
||||
出生时间不是已采用的校正时间时,事实句下加一行「按你填写的时间」。最底固定「仅供自我探索,不替你做决定。」。顶部小字「JYOTISHA · 那 一 刻」。
|
||||
事实句下一行是出生资料:「1994年5月18日 07:40 · 杭州」——本地日期(年月日不补零)、24 小时制时刻(补零)、城市(不带「市」,直辖市、港澳用本名);城市拿不准就只写到时刻,不猜。采用了校正时间的在末尾加「 · 校正后」;按填写时间的不加任何说明(「按你填写的时间」不再使用)。不印区县、经纬度、时区、名字。最底固定「仅供自我探索,不替你做决定。」。顶部小字「JYOTISHA · 那 一 刻」。
|
||||
|
||||
首页开场语不用星星、月亮一类比喻(上文),那一条只管首页问候。封面写的是出生那一刻真实在天上的天体,不是比喻,两者不冲突。
|
||||
|
||||
弹层文案:标题「那一刻的天空」(也是星盘页按钮字),按钮「保存图片」「关闭」。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。
|
||||
弹层文案:星盘页按钮字「那一刻的天空」(也是弹层的无障碍名称,弹层里不再显示标题);弹层里只有图、「保存图片」和一个只有图标的关闭(读屏名「关闭」)。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。
|
||||
|
||||
首次建盘的群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928)里**没有按钮、没有额外文字**,字就是封面上的主句与事实句;原来弹层上的「继续」已删除。过场结束后首页轻提示一次:「这片天空在星盘页可以保存。」——一句陈述,句号结尾,不加感叹、不加 emoji,不催用户去做。报告封面也不加任何文字或按钮。
|
||||
|
||||
@@ -285,6 +285,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
|
||||
|
||||
「解锁完整咨询」兑换弹窗只在第一次因点数不足发不出消息时弹(2026-09-28 起,不再进首页就弹)。说明句写「问题还在输入框里。填写兑换码,点数到账就能发送,生时校正与完整解读也能用。」;到账后写「点数已到账,回到输入框点发送就行。」。不要再写「入门问题已经准备好」,弹出时刚被拦下的是用户自己的问题。
|
||||
|
||||
## 首屏没加载完
|
||||
|
||||
脚本没跑起来、关键文件没下到,或浏览器版本过旧时,转圈停在「正在载入账户」不会自己结束。超时后只留这一屏,不自动刷新:
|
||||
|
||||
@@ -755,6 +755,7 @@ export default function Home() {
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog, openRectificationFromHomepage, openRectificationSession,
|
||||
onboardingPaywallShown, setOnboardingPaywallOpen,
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
&& !starterHomeVisible
|
||||
@@ -901,18 +902,6 @@ export default function Home() {
|
||||
}
|
||||
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiPreview.current
|
||||
|| onboardingPaywallShown.current
|
||||
|| !starterHomeVisible
|
||||
|| !account
|
||||
|| account.credits > 0
|
||||
|| account.activeSubscription?.status === "active"
|
||||
|| activeAccountDialog !== null) return;
|
||||
onboardingPaywallShown.current = true;
|
||||
setOnboardingPaywallOpen(true);
|
||||
}, [account, activeAccountDialog, starterHomeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAccountDialog === null) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import "./birth-sky-converge.css";
|
||||
|
||||
@@ -90,7 +91,9 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
};
|
||||
}, [reveal, onDone]);
|
||||
|
||||
return (
|
||||
// Portalled to <body>, outside SidebarInset, which is inert while any
|
||||
// account dialog or the redeem paywall is open (BUG-968, BUG-1080).
|
||||
return createPortal(
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="birth-sky-converge"
|
||||
@@ -102,6 +105,7 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
role="img"
|
||||
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
|
||||
/>
|
||||
</dialog>
|
||||
</dialog>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./birth-sky.css";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
|
||||
@@ -14,10 +14,14 @@ export type BirthSkyDialogProps = Readonly<{
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
/** Accessible name of the stage; the picture itself carries the words. */
|
||||
const BIRTH_SKY_DIALOG_LABEL = "那一刻的天空";
|
||||
|
||||
/**
|
||||
* Native `<dialog>` like the report export drawer: full screen on phones,
|
||||
* centred on desktop. The picture is an `<img>` of the painted PNG, so a long
|
||||
* press saves it on iOS; 「保存图片」 shares or downloads the same file.
|
||||
* Native `<dialog>` that shows only the picture on a dimmed stage, sized to
|
||||
* the viewport, with 「保存图片」 under it. The picture is an `<img>` of the
|
||||
* painted PNG, so a long press saves it on iOS; 「保存图片」 shares or
|
||||
* downloads the same file.
|
||||
*/
|
||||
export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
@@ -48,27 +52,25 @@ export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
|
||||
}
|
||||
|
||||
const description = `${birthSkyHeadline(cover.sky)}${birthSkyFactLine(cover.sky)}。`;
|
||||
// Poster only (TASK-birth-sky-polish-20260928 P1): no panel, no title bar.
|
||||
// The dialog is the dimmed stage; a click on it (not on the picture or a
|
||||
// button) closes, as do Esc and the icon ×.
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="birth-sky-dialog"
|
||||
aria-labelledby="birth-sky-title"
|
||||
aria-label={BIRTH_SKY_DIALOG_LABEL}
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="birth-sky-panel">
|
||||
<header className="birth-sky-header">
|
||||
<h2 id="birth-sky-title">那一刻的天空</h2>
|
||||
<Button type="button" variant="ghost" onClick={onClose} aria-label="关闭">关闭</Button>
|
||||
</header>
|
||||
<div className="birth-sky-frame">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */}
|
||||
<img className="birth-sky-image" src={cover.url} alt={description} width={1080} height={1440} />
|
||||
</div>
|
||||
<footer className="birth-sky-footer">
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<Button type="button" variant="outline" onClick={() => void save()}>保存图片</Button>
|
||||
</footer>
|
||||
<button className="birth-sky-close" type="button" onClick={onClose} aria-label="关闭">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */}
|
||||
<img className="birth-sky-image" src={cover.url} alt={description} width={1080} height={1440} />
|
||||
<div className="birth-sky-actions">
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<button className="birth-sky-save" type="button" onClick={() => void save()}>保存图片</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928). Loaded with the lazy
|
||||
dialog chunk only, never on first paint. Tokens only; the picture itself is
|
||||
painted on canvas with the night palette. */
|
||||
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928; poster only since
|
||||
TASK-birth-sky-polish-20260928 P1). Loaded with the lazy dialog chunk only,
|
||||
never on first paint. No panel and no title bar: the dialog is a dimmed
|
||||
stage showing the painted picture, 「保存图片」 under it and an icon × in the
|
||||
corner. The stage and controls use the night palette (DESIGN §17: the dark
|
||||
theme floor #1a1a19 and ink #f2f0ea), because the picture is always night. */
|
||||
.birth-sky-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -9,69 +12,78 @@
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: var(--space-4);
|
||||
padding-top: max(var(--space-4), env(safe-area-inset-top));
|
||||
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
border: 0;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.birth-sky-dialog::backdrop { background: var(--color-scrim); }
|
||||
.birth-sky-panel { display: flex; flex-direction: column; height: 100%; }
|
||||
.birth-sky-header {
|
||||
.birth-sky-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
padding-top: max(var(--space-2), env(safe-area-inset-top));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.birth-sky-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 500; }
|
||||
.birth-sky-frame {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 var(--space-4);
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.birth-sky-dialog::backdrop { background: color-mix(in srgb, #1a1a19 90%, transparent); }
|
||||
.birth-sky-image {
|
||||
display: block;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Room for the save row (44px) + gap + stage padding. */
|
||||
max-width: 100%;
|
||||
/* Header + footer ≈ 170px; a percentage would not resolve inside the flex/grid cell everywhere. */
|
||||
max-height: calc(100vh - 170px);
|
||||
max-height: calc(100vh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--radius-md);
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
@supports (height: 1dvh) {
|
||||
.birth-sky-dialog { height: 100dvh; }
|
||||
.birth-sky-image { max-height: calc(100dvh - 170px); }
|
||||
.birth-sky-image { max-height: calc(100dvh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.birth-sky-footer {
|
||||
.birth-sky-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.birth-sky-footer p { flex-basis: 100%; margin: 0; text-align: center; color: var(--color-danger); font-size: var(--type-body-sm); }
|
||||
.birth-sky-dialog button { min-height: 44px; min-width: 44px; }
|
||||
@media (min-width: 860px) {
|
||||
.birth-sky-dialog {
|
||||
inset: 0;
|
||||
width: min(520px, 92vw);
|
||||
height: auto;
|
||||
max-height: 92vh;
|
||||
margin: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
.birth-sky-panel { height: auto; max-height: calc(92vh - 2px); }
|
||||
.birth-sky-image { max-height: calc(92vh - 160px); }
|
||||
.birth-sky-actions p { margin: 0; text-align: center; color: #b3afa4; font-size: var(--type-body-sm); }
|
||||
.birth-sky-save {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.birth-sky-save:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
|
||||
.birth-sky-save:active { transform: scale(.96); }
|
||||
.birth-sky-close {
|
||||
position: absolute;
|
||||
top: max(var(--space-2), env(safe-area-inset-top));
|
||||
right: var(--space-2);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.birth-sky-close:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
|
||||
.birth-sky-close svg { width: 20px; height: 20px; }
|
||||
.birth-sky-save:focus-visible, .birth-sky-close:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; }
|
||||
@media print { .birth-sky-dialog { display: none !important; } }
|
||||
|
||||
@@ -94,7 +94,7 @@ export function OnboardingRedeemPaywall({
|
||||
</header>
|
||||
<div className="paywall-intro">
|
||||
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
|
||||
<p><b>入门问题已经准备好。</b>填写兑换码,解锁后续咨询、生时校正与完整解读。</p>
|
||||
<p><b>问题还在输入框里。</b>填写兑换码,点数到账就能发送,生时校正与完整解读也能用。</p>
|
||||
</div>
|
||||
<div className="redeem-balance">
|
||||
<span>当前可用点数</span>
|
||||
@@ -124,7 +124,7 @@ export function OnboardingRedeemPaywall({
|
||||
{message && <p className="form-success" role="status">{message}</p>}
|
||||
</form>
|
||||
<div className="paywall-footer">
|
||||
<span>{unlocked ? "点数已到账,可以继续选择问题。" : "还没有兑换码?"}</span>
|
||||
<span>{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"}</span>
|
||||
{unlocked ? (
|
||||
<button className="button-primary" type="button" onClick={close}>开始咨询</button>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "reac
|
||||
|
||||
import { composerDraftSnapshot } from "@/lib/composer-draft";
|
||||
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
|
||||
import { tryOpenRedeemPaywall } from "@/lib/redeem-paywall-once";
|
||||
import { useQueuedMessage } from "@/hooks/use-queued-message";
|
||||
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
@@ -150,6 +151,9 @@ export type ConsultationRunParams = {
|
||||
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
|
||||
refreshAccount: () => Promise<void>;
|
||||
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
|
||||
/** Set once the redeem paywall has been shown on this page (TASK-birth-sky-polish-20260928 P4). */
|
||||
onboardingPaywallShown?: MutableRefObject<boolean>;
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise<void>;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
};
|
||||
@@ -209,9 +213,17 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog,
|
||||
onboardingPaywallShown,
|
||||
setOnboardingPaywallOpen,
|
||||
openRectificationFromHomepage,
|
||||
openRectificationSession,
|
||||
} = params;
|
||||
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
|
||||
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
|
||||
uiPreview: uiPreview.current,
|
||||
shown: onboardingPaywallShown,
|
||||
open: setOnboardingPaywallOpen,
|
||||
});
|
||||
const queued = useQueuedMessage();
|
||||
|
||||
function enqueueQueuedDraft(incoming: string) {
|
||||
@@ -614,7 +626,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
: initialConsultationRoute;
|
||||
|
||||
if (account.credits <= 0 && !account.activeSubscription) {
|
||||
openAccountDialog("billing", { source: "insufficient-credits" });
|
||||
if (!openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -854,7 +866,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
// it is open (BUG-1074): same status semantics either way.
|
||||
const rejectRun = (status: number, message: string, code?: string): never => {
|
||||
if (status === 401) window.location.assign("/login");
|
||||
if (status === 402) openAccountDialog("billing", { source: "insufficient-credits" });
|
||||
if (status === 402 && !openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" });
|
||||
throw new ConsultationResponseError(status, message, code);
|
||||
};
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The one line of birth data the cover prints (TASK-birth-sky-polish-20260928
|
||||
* P2, product decision: date + time + city). Built on the server from the
|
||||
* owned profile row; the client only ever receives the finished string.
|
||||
*
|
||||
* Format: `1994年5月18日 07:40 · 杭州`. Local birth time, 24-hour, zero-padded.
|
||||
* No district, coordinates, timezone or name. When the city is not certain the
|
||||
* caption stops at the time — we never guess.
|
||||
*/
|
||||
|
||||
const SEPARATOR = " · ";
|
||||
|
||||
/** Province-level names whose own name is the city (municipalities, SARs). */
|
||||
const CITY_PROVINCES: Readonly<Record<string, string>> = {
|
||||
北京市: "北京",
|
||||
天津市: "天津",
|
||||
上海市: "上海",
|
||||
重庆市: "重庆",
|
||||
香港特别行政区: "香港",
|
||||
澳门特别行政区: "澳门",
|
||||
};
|
||||
|
||||
const CHINA_PROVINCE = /(省|自治区|市|特别行政区)$/;
|
||||
const HAN = /[㐀-鿿]/;
|
||||
|
||||
/**
|
||||
* City from a stored birth place label. Rules, most specific first:
|
||||
*
|
||||
* 1. Municipalities and SARs (`北京市 · 朝阳区`, `香港特别行政区`) → `北京`, `香港`.
|
||||
* 2. Chinese labels `省 · 市 · 区` → the second segment when it ends in 「市」,
|
||||
* without the 「市」 (`河北省 · 邯郸市 · 峰峰矿区` → `邯郸`). Prefectures that are
|
||||
* not cities (盟、地区、自治州、县、林区) and province-only labels → none.
|
||||
* 3. Overseas labels from the world table, `Country · Region · City`, exactly
|
||||
* three segments with no Chinese characters → the third segment.
|
||||
* 4. Anything else (empty, one segment, free text, unknown shape) → none.
|
||||
*/
|
||||
export function birthSkyCity(label: string | null | undefined): string | null {
|
||||
if (typeof label !== "string") return null;
|
||||
const segments = label.split(SEPARATOR).map((segment) => segment.trim()).filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
const [first, second, third] = segments;
|
||||
if (CITY_PROVINCES[first]) return CITY_PROVINCES[first];
|
||||
if (HAN.test(first)) {
|
||||
if (!CHINA_PROVINCE.test(first) || segments.length < 2) return null;
|
||||
if (!second.endsWith("市") || second.length < 2) return null;
|
||||
return second.slice(0, -1);
|
||||
}
|
||||
if (segments.length === 3 && !segments.some((segment) => HAN.test(segment))) return third;
|
||||
return null;
|
||||
}
|
||||
|
||||
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const TIME = /^(\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/;
|
||||
|
||||
/** `1994年5月18日 07:40 · 杭州`, or null when the date or time is not well formed. */
|
||||
export function birthSkyCaption(input: Readonly<{ date: string; time: string; placeLabel?: string | null }>): string | null {
|
||||
const date = DATE.exec(input.date.trim());
|
||||
const time = TIME.exec(input.time.trim());
|
||||
if (!date || !time) return null;
|
||||
const hour = Number(time[1]);
|
||||
if (hour > 23) return null;
|
||||
const moment = `${Number(date[1])}年${Number(date[2])}月${Number(date[3])}日 ${String(hour).padStart(2, "0")}:${time[2]}`;
|
||||
const city = birthSkyCity(input.placeLabel);
|
||||
return city ? `${moment}${SEPARATOR}${city}` : moment;
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { z } from "zod";
|
||||
/**
|
||||
* 「那一刻的天空」 BFF contract (TASK-birth-sky-cover-20260928 T2).
|
||||
*
|
||||
* The response describes only the sky: altitudes, azimuths (degrees from
|
||||
* north, eastward), star magnitudes. It never carries the birth date, time,
|
||||
* coordinates or place — the server reads those from the owned profile row and
|
||||
* keeps them.
|
||||
* The response describes the sky: altitudes, azimuths (degrees from north,
|
||||
* eastward), star magnitudes. The only birth data it carries is `caption`, one
|
||||
* finished line (`1994年5月18日 07:40 · 杭州`, TASK-birth-sky-polish-20260928 P2)
|
||||
* built on the server; no date, time, coordinate, timezone, district or name
|
||||
* field ever leaves it.
|
||||
*/
|
||||
|
||||
export const BIRTH_SKY_PHASES = ["day", "twilight_morning", "twilight_evening", "night"] as const;
|
||||
@@ -45,6 +46,8 @@ export const birthSkyOkSchema = z.strictObject({
|
||||
lines: z.array(birthSkyLineSchema).max(8),
|
||||
/** `reported`: the birth time is the one the user typed, not an adopted one. */
|
||||
timeBasis: z.enum(["adopted", "reported"]),
|
||||
/** Printed under the fact line: local date, time and (when certain) city. */
|
||||
caption: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
export const BIRTH_SKY_FAILURES = [
|
||||
@@ -73,7 +76,11 @@ const engineSchema = z.object({
|
||||
lines: z.array(birthSkyLineSchema),
|
||||
});
|
||||
|
||||
export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["timeBasis"]): BirthSkyOk | null {
|
||||
export function birthSkyFromEngine(
|
||||
payload: unknown,
|
||||
timeBasis: BirthSkyOk["timeBasis"],
|
||||
caption: string,
|
||||
): BirthSkyOk | null {
|
||||
const parsed = engineSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
const candidate = {
|
||||
@@ -84,6 +91,7 @@ export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["time
|
||||
stars: parsed.data.stars.map(({ hr, altitude: alt, azimuth: az, magnitude }) => ({ hr, altitude: alt, azimuth: az, magnitude })),
|
||||
lines: parsed.data.lines.map(({ id, strokes }) => ({ id, strokes })),
|
||||
timeBasis,
|
||||
caption,
|
||||
};
|
||||
const checked = birthSkyOkSchema.safeParse(candidate);
|
||||
return checked.success ? checked.data : null;
|
||||
|
||||
@@ -16,16 +16,16 @@ import {
|
||||
* 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_CONVERGE_MS = 3_000;
|
||||
export const BIRTH_SKY_TEXT_MS = 2_500;
|
||||
export const BIRTH_SKY_FADE_MS = 1_000;
|
||||
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;
|
||||
const TEXT_FADE_IN_MS = 600;
|
||||
/** The layer itself comes in quickly so the page does not flash to dark. */
|
||||
const LAYER_FADE_IN_MS = 200;
|
||||
const LAYER_FADE_IN_MS = 400;
|
||||
/** Reduced motion: no travel, one fade in, a hold, one fade out. */
|
||||
const REDUCED_FADE_IN_MS = 400;
|
||||
const REDUCED_FADE_IN_MS = 600;
|
||||
/** Scattered stars stay inside the horizon so the clip never hides them. */
|
||||
const SCATTER_RADIUS = BIRTH_SKY_RADIUS * 0.95;
|
||||
|
||||
@@ -118,8 +118,9 @@ export type BirthSkyRevealTimeline = Readonly<{
|
||||
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.
|
||||
* Normal (TASK-birth-sky-polish-20260928 P3, slowed from 1.5 + 1 + 0.5 s):
|
||||
* 0–3 s converge (ease-out) with horizon, rings and letters fading in;
|
||||
* 3–5.5 s words fade in and hold; 5.5–6.5 s the layer fades out.
|
||||
* Reduced motion: the finished picture fades in, holds, fades out.
|
||||
*/
|
||||
export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
BIRTH_SKY_PLANET_NAMES,
|
||||
BIRTH_SKY_TOP_LABEL,
|
||||
birthSkyFactLine,
|
||||
birthSkyFootnote,
|
||||
birthSkyCaptionLine,
|
||||
birthSkyHeadline,
|
||||
} from "./sentence.ts";
|
||||
|
||||
@@ -68,8 +68,6 @@ export type BirthSkyDrawOptions = Readonly<{
|
||||
palette: BirthSkyPaletteName | BirthSkyPalette;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Defaults to 「按你填写的时间」 when the birth time is not an adopted one. */
|
||||
footnote?: string | null;
|
||||
/** False when the heading serif did not load in time: draw in the sans stack. */
|
||||
serif?: boolean;
|
||||
}>;
|
||||
@@ -105,7 +103,7 @@ export type BirthSkyFrame = Readonly<{
|
||||
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 frame: top label, rules, headline, fact line, caption, disclaimer. */
|
||||
text: number;
|
||||
/** Scattered start positions keyed by `birthSkyBodyKey`. */
|
||||
scatter: ReadonlyMap<string, SkyPoint>;
|
||||
@@ -153,7 +151,6 @@ export function drawBirthSky(
|
||||
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;
|
||||
|
||||
ctx.save();
|
||||
@@ -276,7 +273,7 @@ export function drawBirthSky(
|
||||
ctx.fillText(text, CENTER_X + dx * (RADIUS + 18), CENTER_Y + dy * (RADIUS + 18) + 4);
|
||||
}
|
||||
|
||||
// Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer.
|
||||
// Text frame (sample C): label, rule, headline … rule, fact, caption, disclaimer.
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = palette.inkSecondary;
|
||||
ctx.globalAlpha = 1 * textAlpha;
|
||||
@@ -299,11 +296,10 @@ export function drawBirthSky(
|
||||
ctx.font = font(600, 15, false);
|
||||
ctx.fillText(birthSkyFactLine(sky), 48, 686, 504);
|
||||
|
||||
if (footnote) {
|
||||
ctx.fillStyle = palette.inkSecondary;
|
||||
ctx.font = font(400, 12.5, false);
|
||||
ctx.fillText(footnote, 48, 712, 504);
|
||||
}
|
||||
// Birth caption (TASK-birth-sky-polish-20260928 P2), where the footnote used to be.
|
||||
ctx.fillStyle = palette.inkSecondary;
|
||||
ctx.font = font(400, 12.5, false);
|
||||
ctx.fillText(birthSkyCaptionLine(sky), 48, 712, 504);
|
||||
|
||||
ctx.fillStyle = palette.inkTertiary;
|
||||
ctx.font = font(400, 11, false);
|
||||
|
||||
@@ -14,7 +14,8 @@ export const BIRTH_SKY_HEADLINES: Readonly<Record<BirthSkyPhase, string>> = {
|
||||
|
||||
export const BIRTH_SKY_TOP_LABEL = "JYOTISHA · 那 一 刻";
|
||||
export const BIRTH_SKY_DISCLAIMER = "仅供自我探索,不替你做决定。";
|
||||
export const BIRTH_SKY_REPORTED_FOOTNOTE = "按你填写的时间";
|
||||
/** Appended to the caption when the time is an adopted rectified one. */
|
||||
export const BIRTH_SKY_ADOPTED_SUFFIX = " · 校正后";
|
||||
|
||||
export const BIRTH_SKY_PLANET_NAMES: Readonly<Record<BirthSkyPlanetId, string>> = {
|
||||
sun: "太阳",
|
||||
@@ -55,6 +56,7 @@ export function birthSkyFactLine(sky: Pick<BirthSkyOk, "phase" | "planets">): st
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
export function birthSkyFootnote(sky: Pick<BirthSkyOk, "timeBasis">): string | null {
|
||||
return sky.timeBasis === "reported" ? BIRTH_SKY_REPORTED_FOOTNOTE : null;
|
||||
/** The birth line under the fact line: the server's caption, plus 「· 校正后」 for an adopted time. */
|
||||
export function birthSkyCaptionLine(sky: Pick<BirthSkyOk, "timeBasis" | "caption">): string {
|
||||
return sky.timeBasis === "adopted" ? `${sky.caption}${BIRTH_SKY_ADOPTED_SUFFIX}` : sky.caption;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prepareChartViewProfile } from "../chart-view-profile-read.ts";
|
||||
import { ConsultationSubjectError, subjectIdFromQuery, type ResolvedSubjectBirth } from "../subject-birth.ts";
|
||||
import { birthSkyCaption } from "./caption.ts";
|
||||
import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts";
|
||||
|
||||
/**
|
||||
@@ -8,7 +9,8 @@ import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts";
|
||||
* Birth truth is read on the server, from the owned profile row, exactly as the
|
||||
* chart page reads it (`loadSubjectBirth` → `prepareChartViewProfile`). No
|
||||
* client date or coordinate is accepted, so the endpoint cannot be used to draw
|
||||
* an arbitrary sky. Never bills, never writes.
|
||||
* an arbitrary sky. Never bills, never writes. The birth data it returns is
|
||||
* the one `caption` line the cover prints, formatted here.
|
||||
*/
|
||||
|
||||
export type BirthSkyEngineBody = Readonly<{
|
||||
@@ -71,7 +73,8 @@ export async function assembleBirthSky(deps: BirthSkyDeps): Promise<BirthSkyResu
|
||||
payload = null;
|
||||
}
|
||||
const timeBasis = ADOPTED_STATUSES.has(profile.birthTimeStatus ?? "") ? "adopted" : "reported";
|
||||
const sky = payload == null ? null : birthSkyFromEngine(payload, timeBasis);
|
||||
const caption = birthSkyCaption({ date: profile.date, time: profile.time, placeLabel: profile.placeLabel });
|
||||
const sky = payload == null || !caption ? null : birthSkyFromEngine(payload, timeBasis, caption);
|
||||
if (!sky) return { httpStatus: 503, body: { status: "unavailable" } };
|
||||
return { httpStatus: 200, body: sky };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* When the redeem paywall opens (TASK-birth-sky-polish-20260928 P4).
|
||||
*
|
||||
* It no longer opens by itself when the starter home appears — that collided
|
||||
* with the first-chart star convergence (BUG-1080). Instead the first send on
|
||||
* this page that is refused for lack of credits (the pre-flight balance check
|
||||
* or a 402) opens it; every later refusal opens billing as before. The caller
|
||||
* keeps the typed question in the composer either way.
|
||||
*/
|
||||
export type RedeemPaywallGate = Readonly<{
|
||||
uiPreview: boolean;
|
||||
/** Page-lifetime flag: the paywall has been shown once. */
|
||||
shown?: { current: boolean };
|
||||
open?: (open: boolean) => void;
|
||||
}>;
|
||||
|
||||
/** True when it opened the paywall; false means the caller opens billing. */
|
||||
export function tryOpenRedeemPaywall(gate: RedeemPaywallGate): boolean {
|
||||
if (gate.uiPreview || !gate.shown || !gate.open || gate.shown.current) return false;
|
||||
gate.shown.current = true;
|
||||
gate.open(true);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { birthSkyCaption, birthSkyCity } from "../src/lib/birth-sky/caption.ts";
|
||||
|
||||
// TASK-birth-sky-polish-20260928 P2. Fictional sample only.
|
||||
|
||||
test("China 省 · 市 · 区 labels give the prefecture city without 「市」", () => {
|
||||
assert.equal(birthSkyCity("河北省 · 邯郸市 · 峰峰矿区"), "邯郸");
|
||||
assert.equal(birthSkyCity("浙江省 · 杭州市 · 西湖区"), "杭州");
|
||||
assert.equal(birthSkyCity("浙江省 · 杭州市"), "杭州");
|
||||
assert.equal(birthSkyCity("广西壮族自治区 · 桂林市 · 秀峰区"), "桂林");
|
||||
});
|
||||
|
||||
test("municipalities and SARs use their own name", () => {
|
||||
assert.equal(birthSkyCity("北京市 · 朝阳区"), "北京");
|
||||
assert.equal(birthSkyCity("上海市"), "上海");
|
||||
assert.equal(birthSkyCity("重庆市 · 渝中区"), "重庆");
|
||||
assert.equal(birthSkyCity("香港特别行政区"), "香港");
|
||||
assert.equal(birthSkyCity("澳门特别行政区"), "澳门");
|
||||
});
|
||||
|
||||
test("no reliable city → none: province only, non-city prefectures, free text, empty", () => {
|
||||
assert.equal(birthSkyCity("浙江省"), null);
|
||||
assert.equal(birthSkyCity("内蒙古自治区 · 锡林郭勒盟 · 二连浩特市"), null);
|
||||
assert.equal(birthSkyCity("吉林省 · 延边朝鲜族自治州 · 延吉市"), null);
|
||||
assert.equal(birthSkyCity("黑龙江省 · 大兴安岭地区"), null);
|
||||
assert.equal(birthSkyCity("台湾省"), null);
|
||||
assert.equal(birthSkyCity("浙江省杭州市"), null);
|
||||
assert.equal(birthSkyCity("杭州"), null);
|
||||
assert.equal(birthSkyCity(""), null);
|
||||
assert.equal(birthSkyCity(null), null);
|
||||
assert.equal(birthSkyCity(undefined), null);
|
||||
});
|
||||
|
||||
test("overseas world-table labels: exactly Country · Region · City, else none", () => {
|
||||
assert.equal(birthSkyCity("Japan · Tokyo · Shinjuku"), "Shinjuku");
|
||||
assert.equal(birthSkyCity("United States · California · San Francisco"), "San Francisco");
|
||||
assert.equal(birthSkyCity("Singapore · Central Singapore"), null);
|
||||
assert.equal(birthSkyCity("Hangzhou, Zhejiang, China"), null);
|
||||
assert.equal(birthSkyCity("Japan · Tokyo · Shinjuku · Extra"), null);
|
||||
});
|
||||
|
||||
test("caption: local date and zero-padded 24-hour time, then the city when certain", () => {
|
||||
assert.equal(birthSkyCaption({ date: "1994-05-18", time: "07:40", placeLabel: "浙江省 · 杭州市 · 西湖区" }), "1994年5月18日 07:40 · 杭州");
|
||||
assert.equal(birthSkyCaption({ date: "1994-05-18", time: "7:40:00", placeLabel: "北京市 · 朝阳区" }), "1994年5月18日 07:40 · 北京");
|
||||
assert.equal(birthSkyCaption({ date: "1994-12-01", time: "19:10", placeLabel: "浙江省" }), "1994年12月1日 19:10");
|
||||
assert.equal(birthSkyCaption({ date: "1994-05-18", time: "00:00" }), "1994年5月18日 00:00");
|
||||
});
|
||||
|
||||
test("caption never carries the district, coordinates or anything malformed", () => {
|
||||
const caption = birthSkyCaption({ date: "1994-05-18", time: "07:40", placeLabel: "浙江省 · 杭州市 · 西湖区" }) ?? "";
|
||||
assert.doesNotMatch(caption, /西湖区|浙江|°|UTC|Asia\//);
|
||||
assert.equal(birthSkyCaption({ date: "1994/05/18", time: "07:40" }), null);
|
||||
assert.equal(birthSkyCaption({ date: "1994-05-18", time: "25:00" }), null);
|
||||
assert.equal(birthSkyCaption({ date: "1994-05-18", time: "" }), null);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
skyPoint,
|
||||
type BirthSkyCanvas,
|
||||
} from "../src/lib/birth-sky/draw.ts";
|
||||
import { birthSkyGolden, goldenSky } from "./birth-sky-test-support.ts";
|
||||
import { goldenCaption, goldenSky } from "./birth-sky-test-support.ts";
|
||||
|
||||
type Call = { method: string; args: unknown[]; state: Record<string, unknown> };
|
||||
|
||||
@@ -51,13 +51,19 @@ test("export size is 1080 x 1440 (3:4) and the background fills it", () => {
|
||||
assert.equal(background?.state.fillStyle, BIRTH_SKY_PALETTES.night.background);
|
||||
});
|
||||
|
||||
test("no drawn text carries a date, a time, coordinates, a place or a name", () => {
|
||||
// 原值: no drawn text carries a date, a time, coordinates, a place or a name.
|
||||
// 新值: only the caption line carries the date, time and city; nothing carries coordinates, a district or a name.
|
||||
// 原因: TASK-birth-sky-polish-20260928 P2 — product overturned 「不印出生资料」 for date + time + city.
|
||||
test("only the caption line carries birth data: date, time, city; never coordinates, district or name", () => {
|
||||
for (const phase of ["day", "twilight_morning", "twilight_evening", "night"] as const) {
|
||||
const texts = draw(phase, "reported").texts();
|
||||
const caption = goldenCaption(phase);
|
||||
assert.ok(texts.includes(caption), caption);
|
||||
const others = texts.filter((text) => text !== caption).join("\n");
|
||||
assert.ok(texts.length >= 8, others);
|
||||
assert.doesNotMatch(others, /\d{4}|\d{1,2}:\d{2}|杭州|年|月\d|日\d/);
|
||||
const joined = texts.join("\n");
|
||||
assert.ok(texts.length >= 8, joined);
|
||||
assert.doesNotMatch(joined, /\d{4}|\d{1,2}:\d{2}|\d+\s*[°′']|\d\s*[NSEW]\b|北纬|东经|杭州|UTC|年|月\d|日\d/);
|
||||
assert.doesNotMatch(joined, new RegExp(birthSkyGolden.times[phase]));
|
||||
assert.doesNotMatch(joined, /\d+\s*[°′']|\d\s*[NSEW]\b|北纬|东经|UTC|西湖区|浙江/);
|
||||
assert.doesNotMatch(joined, /示例|虚构|王芳/);
|
||||
}
|
||||
const canvas = draw("day", "reported");
|
||||
@@ -66,13 +72,25 @@ test("no drawn text carries a date, a time, coordinates, a place or a name", ()
|
||||
assert.equal(topLabel, "JYOTISHA · 那 一 刻");
|
||||
assert.ok(texts.includes("星星都在,只是还看不见。"));
|
||||
assert.ok(texts.includes("太阳在东,月亮在脚下,火星正悬在东南"));
|
||||
assert.ok(texts.includes("1994年5月18日 07:40 · 杭州"));
|
||||
assert.ok(texts.includes("仅供自我探索,不替你做决定。"));
|
||||
for (const direction of ["北", "南", "东", "西"]) assert.ok(texts.includes(direction));
|
||||
});
|
||||
|
||||
test("「按你填写的时间」 appears for a reported time and not for an adopted one", () => {
|
||||
assert.ok(draw("day", "reported").texts().includes("按你填写的时间"));
|
||||
assert.ok(!draw("day", "adopted").texts().includes("按你填写的时间"));
|
||||
// 原值: 「按你填写的时间」 for a reported time, nothing for an adopted one.
|
||||
// 新值: the caption sits where the footnote was (y = 712); adopted appends 「 · 校正后」; 「按你填写的时间」 is gone.
|
||||
// 原因: TASK-birth-sky-polish-20260928 P2.
|
||||
test("the caption replaces the footnote; an adopted time adds 「· 校正后」", () => {
|
||||
const reported = draw("day", "reported");
|
||||
const adopted = draw("day", "adopted");
|
||||
const at712 = (canvas: ReturnType<typeof draw>) => canvas.calls.filter((call) => call.method === "fillText" && call.args[2] === 712).map((call) => call.args[0]);
|
||||
assert.deepEqual(at712(reported), ["1994年5月18日 07:40 · 杭州"]);
|
||||
assert.deepEqual(at712(adopted), ["1994年5月18日 07:40 · 杭州 · 校正后"]);
|
||||
for (const canvas of [reported, adopted]) assert.ok(!canvas.texts().includes("按你填写的时间"));
|
||||
const paper = draw("twilight_evening", "reported", "paper");
|
||||
const line = paper.calls.find((call) => call.method === "fillText" && call.args[2] === 712);
|
||||
assert.equal(line?.args[0], "1994年5月18日 19:10 · 杭州");
|
||||
assert.equal(line?.state.fillStyle, BIRTH_SKY_PALETTES.paper.inkSecondary);
|
||||
});
|
||||
|
||||
test("east is on the left, north is up", () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { tryOpenRedeemPaywall } from "../src/lib/redeem-paywall-once.ts";
|
||||
|
||||
// TASK-birth-sky-polish-20260928 P1 (poster-only dialog) and P4 (paywall timing, BUG-1080).
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const dialog = read("src/components/birth-sky/birth-sky-dialog.tsx");
|
||||
const dialogCss = read("src/components/birth-sky/birth-sky.css");
|
||||
const converge = read("src/components/birth-sky/birth-sky-converge.tsx");
|
||||
const hook = read("src/hooks/use-consultation-run.ts");
|
||||
const page = read("src/app/(app)/page.tsx");
|
||||
|
||||
test("chart page dialog shows only the picture: no title bar, no panel; icon × and 「保存图片」", () => {
|
||||
assert.doesNotMatch(dialog, /<h2|<header|birth-sky-panel|birth-sky-header|birth-sky-frame|birth-sky-footer|>关闭</);
|
||||
assert.doesNotMatch(dialogCss, /birth-sky-panel|birth-sky-header|birth-sky-frame|birth-sky-footer/);
|
||||
assert.match(dialog, /<button className="birth-sky-close" type="button" onClick=\{onClose\} aria-label="关闭">\s*<X aria-hidden="true" \/>/);
|
||||
assert.match(dialog, /<img className="birth-sky-image" src=\{cover\.url\}/);
|
||||
assert.match(dialog, /<button className="birth-sky-save" type="button" onClick=\{\(\) => void save\(\)\}>保存图片<\/button>/);
|
||||
assert.match(dialog, /aria-label=\{BIRTH_SKY_DIALOG_LABEL\}/);
|
||||
// Backdrop click (the stage itself, not the picture or a button) and Esc both close.
|
||||
assert.match(dialog, /onClick=\{\(event\) => \{ if \(event\.target === event\.currentTarget\) onClose\(\); \}\}/);
|
||||
assert.match(dialog, /onCancel=\{\(event\) => \{ event\.preventDefault\(\); onClose\(\); \}\}/);
|
||||
// The stage is transparent and the backdrop is the night floor; the picture is sized to the viewport.
|
||||
assert.match(dialogCss, /\.birth-sky-dialog \{[^}]*background: transparent;/);
|
||||
assert.match(dialogCss, /\.birth-sky-dialog::backdrop \{ background: color-mix\(in srgb, #1a1a19 90%, transparent\); \}/);
|
||||
assert.match(dialogCss, /\.birth-sky-image \{[^}]*max-width: 100%;[^}]*max-height: calc\(100vh - 44px/);
|
||||
assert.match(dialogCss, /\.birth-sky-save \{[^}]*min-height: 44px;/);
|
||||
assert.match(dialogCss, /\.birth-sky-close \{[^}]*width: 44px;[^}]*height: 44px;/);
|
||||
assert.doesNotMatch(dialogCss, /@media \(min-width: 860px\)/, "no desktop panel variant");
|
||||
});
|
||||
|
||||
test("paywall gate: the first refusal opens the paywall, every later one falls through to billing", () => {
|
||||
const shown = { current: false };
|
||||
const opened: boolean[] = [];
|
||||
const open = (value: boolean) => { opened.push(value); };
|
||||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown, open }), true);
|
||||
assert.equal(shown.current, true);
|
||||
assert.deepEqual(opened, [true]);
|
||||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown, open }), false, "second refusal → billing");
|
||||
assert.deepEqual(opened, [true]);
|
||||
});
|
||||
|
||||
test("paywall gate: UI preview or a caller without the paywall never opens it", () => {
|
||||
const shown = { current: false };
|
||||
const opened: boolean[] = [];
|
||||
assert.equal(tryOpenRedeemPaywall({ uiPreview: true, shown, open: (value) => opened.push(value) }), false);
|
||||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false }), false);
|
||||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown }), false);
|
||||
assert.equal(shown.current, false);
|
||||
assert.deepEqual(opened, []);
|
||||
});
|
||||
|
||||
test("the paywall only opens from a refused send; the typed question stays in the composer", () => {
|
||||
// No page effect opens it when the starter home appears.
|
||||
assert.doesNotMatch(page, /setOnboardingPaywallOpen\(true\)/);
|
||||
assert.doesNotMatch(page, /!starterHomeVisible\s*\n\s*\|\| !account/);
|
||||
// Pre-flight balance check: before send() clears the draft, paywall first, else billing.
|
||||
const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription) {"));
|
||||
assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/);
|
||||
assert.ok(hook.indexOf("if (!openRedeemPaywallOnce())") < hook.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");"));
|
||||
// 402: same order; the existing rollback restores the draft (reserve did not commit).
|
||||
assert.match(hook, /if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
|
||||
assert.match(hook, /\|\| caught\.status === 402[\s\S]{0,200}if \(reserveDidNotCommit\) \{[\s\S]{0,300}setDraft\(originalQuestion\);/);
|
||||
// Rectification's own 402 path is out of scope and unchanged.
|
||||
assert.match(read("src/lib/rectification-chat-turn-run.ts"), /response\.status === 402/);
|
||||
});
|
||||
|
||||
test("the convergence overlay is portalled to <body>, outside the inert SidebarInset (BUG-968, BUG-1080)", () => {
|
||||
assert.match(converge, /import \{ createPortal \} from "react-dom";/);
|
||||
assert.match(converge, /return createPortal\(\s*<dialog[\s\S]*<\/dialog>,\s*document\.body,\s*\);/);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const selfRow = {
|
||||
longitude: 120.15,
|
||||
timezone_offset: 8,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
birth_place_label: "虚构地点",
|
||||
birth_place_label: "浙江省 · 杭州市 · 西湖区",
|
||||
ayanamsa: "lahiri",
|
||||
};
|
||||
|
||||
@@ -83,7 +83,11 @@ test("an incomplete profile says so, so the page shows no entry", async () => {
|
||||
assert.equal(engineCalls.length, 0);
|
||||
});
|
||||
|
||||
test("a complete profile returns the contract and leaks no birth data", async () => {
|
||||
// 原值: the JSON carries no date / time / place key and none of 1994, 07:40, 30.27, 120.15, 虚构, Asia/Shanghai.
|
||||
// 新值: date, time and city appear only inside the finished `caption` string; outside it the JSON is unchanged,
|
||||
// and no key or value anywhere carries coordinates, timezone, district, province or name.
|
||||
// 原因: TASK-birth-sky-polish-20260928 P2 — product decided the cover prints date + time + city.
|
||||
test("a complete profile returns the contract; birth data leaves only as the caption line", async () => {
|
||||
const { deps: input, engineCalls } = deps();
|
||||
const result = await assembleBirthSky(input);
|
||||
assert.equal(result.httpStatus, 200);
|
||||
@@ -92,14 +96,32 @@ test("a complete profile returns the contract and leaks no birth data", async ()
|
||||
if (result.body.status !== "ok") return;
|
||||
assert.equal(result.body.phase, "day");
|
||||
assert.equal(result.body.timeBasis, "reported");
|
||||
assert.equal(result.body.caption, "1994年5月18日 07:40 · 杭州");
|
||||
assert.deepEqual(engineCalls, [{ date: "1994-05-18", time: "07:40", timezone_offset: 8, latitude: 30.27, longitude: 120.15 }]);
|
||||
const json = JSON.stringify(result.body);
|
||||
for (const key of ["latitude", "longitude", "date", "time", "timezone", "birth_place_label", "placeLabel", "name"]) {
|
||||
assert.doesNotMatch(json, new RegExp(`"${key}"`), key);
|
||||
}
|
||||
for (const value of ["1994", "07:40", "30.27", "120.15", "虚构", "Asia/Shanghai"]) {
|
||||
for (const value of ["30.27", "120.15", "虚构", "Asia/Shanghai", "西湖区", "浙江", "UTC"]) {
|
||||
assert.ok(!json.includes(value), value);
|
||||
}
|
||||
const outside = JSON.stringify({ ...result.body, caption: null });
|
||||
for (const value of ["1994", "07:40", "杭州"]) {
|
||||
assert.ok(!outside.includes(value), value);
|
||||
}
|
||||
});
|
||||
|
||||
test("an uncertain city stops the caption at the time", async () => {
|
||||
const { deps: input } = deps({
|
||||
loadSubject: (userId, subjectId) => resolveSubjectBirth({
|
||||
userId,
|
||||
subjectId,
|
||||
loadSelfRow: async () => ({ ...selfRow, birth_place_label: "虚构地点" }),
|
||||
loadOtherRow: async () => null,
|
||||
}),
|
||||
});
|
||||
const result = await assembleBirthSky(input);
|
||||
assert.equal(result.body.status === "ok" ? result.body.caption : null, "1994年5月18日 07:40");
|
||||
});
|
||||
|
||||
test("an adopted birth time is labelled adopted; engine failure shows nothing", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
BIRTH_SKY_HEADLINES,
|
||||
birthSkyFactLine,
|
||||
birthSkyFootnote,
|
||||
birthSkyCaptionLine,
|
||||
birthSkyHeadline,
|
||||
octantName,
|
||||
} from "../src/lib/birth-sky/sentence.ts";
|
||||
@@ -67,7 +67,11 @@ test("eight-point compass rounds to the nearest 45 degrees, north at both ends",
|
||||
assert.equal(octantName(350), "北");
|
||||
});
|
||||
|
||||
test("the reported-time footnote appears only when the time was not adopted", () => {
|
||||
assert.equal(birthSkyFootnote({ timeBasis: "reported" }), "按你填写的时间");
|
||||
assert.equal(birthSkyFootnote({ timeBasis: "adopted" }), null);
|
||||
// 原值: 「按你填写的时间」 footnote for a reported time, none for an adopted one.
|
||||
// 新值: the caption line; an adopted time appends 「 · 校正后」, a reported one adds nothing.
|
||||
// 原因: TASK-birth-sky-polish-20260928 P2 — the cover prints date, time and city in that line.
|
||||
test("the caption line appends 「· 校正后」 only for an adopted time", () => {
|
||||
const caption = "1994年5月18日 07:40 · 杭州";
|
||||
assert.equal(birthSkyCaptionLine({ timeBasis: "reported", caption }), caption);
|
||||
assert.equal(birthSkyCaptionLine({ timeBasis: "adopted", caption }), "1994年5月18日 07:40 · 杭州 · 校正后");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { birthSkyCaption } from "../src/lib/birth-sky/caption.ts";
|
||||
import { birthSkyFromEngine, type BirthSkyOk, type BirthSkyPhase } from "../src/lib/birth-sky/contract.ts";
|
||||
|
||||
/** Real engine responses (scripts/generate_birth_sky_golden.py), fictional sample birth. */
|
||||
@@ -7,8 +8,18 @@ export const birthSkyGolden = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/birth-sky-golden.json", import.meta.url), "utf8"),
|
||||
) as { times: Record<BirthSkyPhase, string>; responses: Record<BirthSkyPhase, Record<string, unknown>> };
|
||||
|
||||
/** Fictional sample place label, in the China picker's `省 · 市 · 区` shape. */
|
||||
export const GOLDEN_PLACE_LABEL = "浙江省 · 杭州市 · 西湖区";
|
||||
|
||||
/** The caption the BFF would build for the fictional sample at this phase's time. */
|
||||
export function goldenCaption(phase: BirthSkyPhase): string {
|
||||
const caption = birthSkyCaption({ date: "1994-05-18", time: birthSkyGolden.times[phase], placeLabel: GOLDEN_PLACE_LABEL });
|
||||
if (!caption) throw new Error(`golden ${phase} has no caption`);
|
||||
return caption;
|
||||
}
|
||||
|
||||
export function goldenSky(phase: BirthSkyPhase, timeBasis: BirthSkyOk["timeBasis"] = "reported"): BirthSkyOk {
|
||||
const sky = birthSkyFromEngine(birthSkyGolden.responses[phase], timeBasis);
|
||||
const sky = birthSkyFromEngine(birthSkyGolden.responses[phase], timeBasis, goldenCaption(phase));
|
||||
if (!sky) throw new Error(`golden ${phase} does not satisfy the contract`);
|
||||
return sky;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr
|
||||
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
|
||||
// 新值: 同一句在 rejectRun 里,HTTP 体与流内 request_rejected 事件共用(状态从参数来)
|
||||
// 原因: TASK-consult-first-frame-and-pacing-20260928 D2 / BUG-1074:开流后拒绝改走事件,调用点数不变(下面仍数到 2)
|
||||
assert.match(pageSource, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||||
// 原值: /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
|
||||
// 新值: 同一句前加 `&& !openRedeemPaywallOnce()`:本页第一次 402 打开兑换弹窗,之后仍打开 billing
|
||||
// 原因: TASK-birth-sky-polish-20260928 P4:兑换弹窗从「进首页就弹」改到第一次因点数不足发不出消息时
|
||||
assert.match(pageSource, /if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||||
|
||||
// And: membership stays in-page; leaving chat is a client-side <Link> now.
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
|
||||
|
||||
@@ -294,7 +294,10 @@ test("the client reads smalltalk and rejections from events, with the same statu
|
||||
assert.doesNotMatch(hook, /x-jyotish-response-kind/);
|
||||
assert.match(hook, /if \(event\.responseKind === "smalltalk"\) \{\s*responseKind = "smalltalk";/);
|
||||
assert.match(hook, /event\.type === "run\.failed" && event\.code === "request_rejected"/);
|
||||
assert.match(hook, /const rejectRun = \(status: number, message: string, code\?: string\): never => \{\s*if \(status === 401\) window\.location\.assign\("\/login"\);\s*if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
|
||||
// 原值: …if \(status === 402\) openAccountDialog\("billing", …\);
|
||||
// 新值: …if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", …\);
|
||||
// 原因: TASK-birth-sky-polish-20260928 P4:本页第一次因点数不足被拒时先打开兑换弹窗,之后照旧 billing;状态语义不变
|
||||
assert.match(hook, /const rejectRun = \(status: number, message: string, code\?: string\): never => \{\s*if \(status === 401\) window\.location\.assign\("\/login"\);\s*if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
|
||||
assert.match(hook, /rejectRun\(response\.status, payloadMessage\(errorPayload, "服务暂时不可用"\), payloadCode\(errorPayload\)\);/);
|
||||
assert.match(hook, /rejectRun\(event\.status \?\? 503, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/);
|
||||
// One redirect and one billing prompt for both paths (chat-navigation-a11y-contract counts them).
|
||||
|
||||
@@ -33,13 +33,25 @@ test("homepage composer does not render notice copy", () => {
|
||||
assert.doesNotMatch(homePageSource, /composer-notice/);
|
||||
});
|
||||
|
||||
test("finished starter questions open the redeem paywall only for accounts without access", () => {
|
||||
assert.match(homePageSource, /!starterHomeVisible/);
|
||||
assert.match(homePageSource, /account\.credits > 0/);
|
||||
assert.match(homePageSource, /account\.activeSubscription\?\.status === "active"/);
|
||||
assert.match(homePageSource, /setOnboardingPaywallOpen\(true\)/);
|
||||
// 原值: 名为 "finished starter questions open the redeem paywall only for accounts without access";
|
||||
// page.tsx 里有「!starterHomeVisible / credits > 0 / activeSubscription active」守卫的 effect 调 setOnboardingPaywallOpen(true);
|
||||
// 付费墙文案含「入门问题已经准备好」。
|
||||
// 新值: 进首页不再自动弹;useConsultationRun 在本页第一次因点数不足发不出消息(前置余额检查或 402)时打开兑换弹窗,
|
||||
// 之后照旧 billing;文案改为「问题还在输入框里。」。
|
||||
// 原因: TASK-birth-sky-polish-20260928 P4 / BUG-1080:新账号首次建盘时兑换弹窗与群星过场同时出现,产品改为发第一条消息时再弹。
|
||||
test("the redeem paywall opens on the first send refused for credits, not when the starter home appears", () => {
|
||||
const hookSource = readProjectFile("src/hooks/use-consultation-run.ts");
|
||||
const pageFile = readProjectFile("src/app/(app)/page.tsx");
|
||||
assert.doesNotMatch(pageFile, /setOnboardingPaywallOpen\(true\)/);
|
||||
assert.doesNotMatch(pageFile, /onboardingPaywallShown\.current = true/);
|
||||
assert.match(pageFile, /onboardingPaywallShown, setOnboardingPaywallOpen,\n\s*\}\);/);
|
||||
assert.match(hookSource, /const openRedeemPaywallOnce = \(\) => tryOpenRedeemPaywall\(\{\s*uiPreview: uiPreview\.current,\s*shown: onboardingPaywallShown,\s*open: setOnboardingPaywallOpen,\s*\}\);/);
|
||||
// Both refusal paths (pre-flight balance check and 402) try the paywall first, then billing.
|
||||
assert.equal(hookSource.match(/if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 1);
|
||||
assert.equal(hookSource.match(/if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 1);
|
||||
assert.match(homePageSource, /<OnboardingRedeemPaywall/);
|
||||
assert.match(onboardingPaywallSource, /入门问题已经准备好/);
|
||||
assert.match(onboardingPaywallSource, /问题还在输入框里。/);
|
||||
assert.doesNotMatch(onboardingPaywallSource, /入门问题已经准备好/);
|
||||
assert.match(onboardingPaywallSource, /fetch\("\/api\/redeem"/);
|
||||
assert.match(onboardingPaywallSource, /兑换并继续/);
|
||||
// 原值: membershipHref("onboarding-paywall") 的 Link
|
||||
|
||||
@@ -191,29 +191,38 @@ test("the settled frame is the exported cover, call for call; earlier frames mov
|
||||
assert.match(converge, /drawBirthSky\(context as BirthSkyCanvas, reveal\.sky, \{/);
|
||||
});
|
||||
|
||||
test("timeline: converge ~1.5 s, words ~1 s, fade ~0.5 s; reduced motion only fades in, holds, fades out", () => {
|
||||
assert.equal(BIRTH_SKY_CONVERGE_MS, 1_500);
|
||||
assert.equal(BIRTH_SKY_TEXT_MS, 1_000);
|
||||
assert.equal(BIRTH_SKY_FADE_MS, 500);
|
||||
assert.equal(BIRTH_SKY_REVEAL_TOTAL_MS, 3_000);
|
||||
// 原值: converge 1_500 / words 1_000 / fade 500 ms, total 3_000; checkpoints 750, 1_500, 2_400, 2_750, 3_000 ms.
|
||||
// 新值: converge 3_000 / words 2_500 / fade 1_000 ms, total 6_500; checkpoints scaled to the new clock.
|
||||
// 原因: TASK-birth-sky-polish-20260928 P3 — product asked for a slower reveal.
|
||||
test("timeline: converge ~3 s, words ~2.5 s, fade ~1 s; reduced motion only fades in, holds, fades out", () => {
|
||||
assert.equal(BIRTH_SKY_CONVERGE_MS, 3_000);
|
||||
assert.equal(BIRTH_SKY_TEXT_MS, 2_500);
|
||||
assert.equal(BIRTH_SKY_FADE_MS, 1_000);
|
||||
assert.equal(BIRTH_SKY_REVEAL_TOTAL_MS, 6_500);
|
||||
const at = (ms: number) => birthSkyRevealAt(ms, false);
|
||||
assert.deepEqual(at(0).frame, { converge: 0, chrome: 0, text: 0 });
|
||||
assert.ok(at(750).frame.converge > 0.5, "ease-out: more than half way at half time");
|
||||
assert.equal(at(750).frame.text, 0);
|
||||
assert.equal(at(1_500).frame.converge, 1);
|
||||
assert.equal(at(2_400).frame.text, 1);
|
||||
assert.equal(at(2_400).layer, 1);
|
||||
assert.ok(at(2_750).layer > 0 && at(2_750).layer < 1);
|
||||
assert.equal(at(2_999).done, false);
|
||||
assert.equal(at(3_000).done, true);
|
||||
assert.equal(at(3_000).layer, 0);
|
||||
for (const ms of [0, 200, 1_000, 2_000, 2_700]) {
|
||||
assert.ok(at(1_500).frame.converge > 0.5, "ease-out: more than half way at half time");
|
||||
assert.equal(at(1_500).frame.text, 0);
|
||||
assert.ok(at(200).layer > 0 && at(200).layer < 1, "the layer comes in over 400 ms");
|
||||
assert.equal(at(400).layer, 1);
|
||||
assert.equal(at(3_000).frame.converge, 1);
|
||||
assert.ok(at(3_300).frame.text > 0 && at(3_300).frame.text < 1, "words fade in over 600 ms");
|
||||
assert.equal(at(3_600).frame.text, 1);
|
||||
assert.equal(at(5_400).frame.text, 1);
|
||||
assert.equal(at(5_400).layer, 1);
|
||||
assert.ok(at(6_000).layer > 0 && at(6_000).layer < 1);
|
||||
assert.equal(at(6_499).done, false);
|
||||
assert.equal(at(6_500).done, true);
|
||||
assert.equal(at(6_500).layer, 0);
|
||||
for (const ms of [0, 300, 2_000, 4_000, 5_800]) {
|
||||
assert.deepEqual(birthSkyRevealAt(ms, true).frame, { converge: 1, chrome: 1, text: 1 }, `no travel at ${ms}ms`);
|
||||
}
|
||||
assert.equal(birthSkyRevealAt(0, true).layer, 0);
|
||||
assert.equal(birthSkyRevealAt(1_000, true).layer, 1);
|
||||
assert.ok(birthSkyRevealAt(2_750, true).layer < 1);
|
||||
assert.equal(birthSkyRevealAt(3_000, true).done, true);
|
||||
assert.ok(birthSkyRevealAt(300, true).layer < 1, "reduced motion fades in over 600 ms");
|
||||
assert.equal(birthSkyRevealAt(600, true).layer, 1);
|
||||
assert.equal(birthSkyRevealAt(5_400, true).layer, 1);
|
||||
assert.ok(birthSkyRevealAt(6_000, true).layer < 1);
|
||||
assert.equal(birthSkyRevealAt(6_500, true).done, true);
|
||||
assert.match(converge, /window\.matchMedia\?\.\("\(prefers-reduced-motion: reduce\)"\)/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user