Files
Jyotisha/docs/tasks/PROGRESS-birth-sky-polish-20260928.md
T
Jesse_ChenandClaude Opus 5.5 adbb205440
Independent Staging Quality Gate / validate (push) Successful in 14m0s
Independent Staging Quality Gate / publish (push) Successful in 20m44s
docs(tasks): Claude acceptance for birth-sky polish
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-29 00:08:47 +08:00

135 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。
- 未推送、未部署。
## Claude 验收(2026-09-29,`dec36bce`)
独立重跑:`tsc` 0 错;lint 0 error / 127 warning;`npm test` 4322 · pass 4267 · fail 24,失败清单与基线逐条同名(Docker / DB / 部署);pytest(birth_sky + 增长合同 + 隐私)85 passed;`rm -rf .next && next build` `/` ○ Static;首屏 gzip(Claude 口径,基线 `be9e4dbe` 同代码实测 650,883 / 66,126)→ 650,972 / 66,126,JS +89 B(+0.01%)。`page.tsx` 1375 → 1364,`useState`/`useRef` 数 70 → 70。看过弹层三视口截图与三张海报:无面板、caption 在事实句下。BFF 只多一个 `caption` 字符串。
偏离裁定:1(0 点账户首条任何消息都弹兑换,包括闲聊)接受——客户端余额预检本来就拦住所有发送,改它属计费行为,不在本单;2–5 接受;6 新文案接受,待产品真机看。BUG-1080 维持 investigating(Chrome 复现证伪「被遮挡」),真机新账号复核。