diff --git a/CHANGELOG.md b/CHANGELOG.md index d553221c..5999c070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 时封面换成浅色纸面版,单独占第一页。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 12b6b97b..2da65e9f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`,而过场 `` 渲染在 `Home()` 里、也就是这棵 inert 子树内部。 +- 已排除 / 已确认: + 1. **「兑换弹窗盖住过场」在 Chrome 里不成立(已用无头 Chrome 151 最小复现排除)**:`inert` 容器里先放兑换弹层(portal 到 body、z-index 40、输入框已聚焦),再对容器内的 `` 调 `showModal()`:不抛错、`:modal` 为真,截图里过场在最上层完整可见,`elementFromPoint` 命中过场画布,CDP 真实鼠标按下与 Esc 都送达过场(`pointerdown`、`cancel` 事件都触发)。与 BUG-968 不同:BUG-968 的账户弹窗是普通 `div`,不进顶层,所以会被 `inert` 锁住;`.showModal()` 进顶层。复现页与截图:`scratchpad/polish/bug1080/repro.html`、`inert.png`、`plain.png`(会话 scratchpad,不进仓库)。Safari 未能验证。 + 2. 仍可能的原因(未取证):① 3 秒预算内没拿齐数据(`/api/birth-sky` 冷启动、宋体切片等待最多 1.5 秒、过场 chunk 下载),过场被静默跳过,页面上只剩兑换弹窗;② Safari / iOS 对 inert 子树内顶层 `` 的处理与 Chrome 不同;③ 产品当时的账号不是在首页 onboarding 里保存出生地(例如从设置或星盘档案补资料),本来就不触发。 +- 修复(`TASK-birth-sky-polish-20260928` P4,不依赖根因结论):① 删掉「进入 starter home 就弹兑换弹窗」的 effect;兑换弹窗改为本页第一次因点数不足发不出消息(前置余额检查或 402)时弹,之后照旧打开 billing(`lib/redeem-paywall-once.ts` + `useConsultationRun`),草稿留在输入框。建盘后首页不再有任何自动弹窗与过场抢位置。② 过场 `` 改为 `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 最小复现见上。 +- 防复发:首页不得在「本人刚保存出生地」这一刻自动弹出任何弹窗;顶层 `` 也要 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`(本条所在提交),未部署。 diff --git a/docs/tasks/PROGRESS-birth-sky-polish-20260928.md b/docs/tasks/PROGRESS-birth-sky-polish-20260928.md new file mode 100644 index 00000000..05dfc12c --- /dev/null +++ b/docs/tasks/PROGRESS-birth-sky-polish-20260928.md @@ -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`:删掉 `
`(标题「那一刻的天空」+「关闭」文字按钮)与面板 / 框 / 页脚包裹;原生 `` 只含右上角图标 ×(`aria-label="关闭"`,lucide `X`)、``、「保存图片」一行(保存失败提示仍在它上方)。弹层无障碍名称改为 `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`)里放过场同款 ``;先把兑换弹层 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(, 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。 +- 未推送、未部署。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e61ce8df..32153fc6 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -250,7 +250,7 @@ | `TASK-site-button-contrast-20260928.md` | `PROGRESS-site-button-contrast-20260928.md` | **全站实心按钮配色与禁用态统一**:层外 `button { color: inherit }` 压掉 Tailwind 文字色(61 个 ` -
-
- {/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */} - {description} -
-
- {error ?

{error}

: null} - -
+ + {/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */} + {description} +
+ {error ?

{error}

: null} +
); diff --git a/frontend/src/components/birth-sky/birth-sky.css b/frontend/src/components/birth-sky/birth-sky.css index b3524b1b..71b9f676 100644 --- a/frontend/src/components/birth-sky/birth-sky.css +++ b/frontend/src/components/birth-sky/birth-sky.css @@ -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; } } diff --git a/frontend/src/components/onboarding-redeem-paywall.tsx b/frontend/src/components/onboarding-redeem-paywall.tsx index 8c6341c8..679f19bb 100644 --- a/frontend/src/components/onboarding-redeem-paywall.tsx +++ b/frontend/src/components/onboarding-redeem-paywall.tsx @@ -94,7 +94,7 @@ export function OnboardingRedeemPaywall({
-

入门问题已经准备好。填写兑换码,解锁后续咨询、生时校正与完整解读。

+

问题还在输入框里。填写兑换码,点数到账就能发送,生时校正与完整解读也能用。

当前可用点数 @@ -124,7 +124,7 @@ export function OnboardingRedeemPaywall({ {message &&

{message}

}
- {unlocked ? "点数已到账,可以继续选择问题。" : "还没有兑换码?"} + {unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"} {unlocked ? ( ) : ( diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index e48debee..7252ab58 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -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; refreshAccount: () => Promise; 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; + setOnboardingPaywallOpen?: (open: boolean) => void; openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise; openRectificationSession: (exactSessionId: string) => Promise | 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) { diff --git a/frontend/src/lib/birth-sky/caption.ts b/frontend/src/lib/birth-sky/caption.ts new file mode 100644 index 00000000..1e1a9519 --- /dev/null +++ b/frontend/src/lib/birth-sky/caption.ts @@ -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> = { + 北京市: "北京", + 天津市: "天津", + 上海市: "上海", + 重庆市: "重庆", + 香港特别行政区: "香港", + 澳门特别行政区: "澳门", +}; + +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; +} diff --git a/frontend/src/lib/birth-sky/contract.ts b/frontend/src/lib/birth-sky/contract.ts index c79064ef..57be9616 100644 --- a/frontend/src/lib/birth-sky/contract.ts +++ b/frontend/src/lib/birth-sky/contract.ts @@ -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; diff --git a/frontend/src/lib/birth-sky/converge.ts b/frontend/src/lib/birth-sky/converge.ts index e8a9d14e..f282be0a 100644 --- a/frontend/src/lib/birth-sky/converge.ts +++ b/frontend/src/lib/birth-sky/converge.ts @@ -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 { diff --git a/frontend/src/lib/birth-sky/draw.ts b/frontend/src/lib/birth-sky/draw.ts index f5c22f0c..fd285af6 100644 --- a/frontend/src/lib/birth-sky/draw.ts +++ b/frontend/src/lib/birth-sky/draw.ts @@ -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; @@ -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); diff --git a/frontend/src/lib/birth-sky/sentence.ts b/frontend/src/lib/birth-sky/sentence.ts index 1e36fd37..7099b0cb 100644 --- a/frontend/src/lib/birth-sky/sentence.ts +++ b/frontend/src/lib/birth-sky/sentence.ts @@ -14,7 +14,8 @@ export const BIRTH_SKY_HEADLINES: Readonly> = { 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> = { sun: "太阳", @@ -55,6 +56,7 @@ export function birthSkyFactLine(sky: Pick): st return parts.join(","); } -export function birthSkyFootnote(sky: Pick): 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): string { + return sky.timeBasis === "adopted" ? `${sky.caption}${BIRTH_SKY_ADOPTED_SUFFIX}` : sky.caption; } diff --git a/frontend/src/lib/birth-sky/service-core.ts b/frontend/src/lib/birth-sky/service-core.ts index 74a017bd..7b8b0f5f 100644 --- a/frontend/src/lib/birth-sky/service-core.ts +++ b/frontend/src/lib/birth-sky/service-core.ts @@ -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 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; +} diff --git a/frontend/tests/birth-sky-caption.test.ts b/frontend/tests/birth-sky-caption.test.ts new file mode 100644 index 00000000..793d8e1f --- /dev/null +++ b/frontend/tests/birth-sky-caption.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-sky-draw.test.ts b/frontend/tests/birth-sky-draw.test.ts index 41596d79..5be48302 100644 --- a/frontend/tests/birth-sky-draw.test.ts +++ b/frontend/tests/birth-sky-draw.test.ts @@ -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 }; @@ -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) => 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", () => { diff --git a/frontend/tests/birth-sky-polish-20260928.test.ts b/frontend/tests/birth-sky-polish-20260928.test.ts new file mode 100644 index 00000000..be4af409 --- /dev/null +++ b/frontend/tests/birth-sky-polish-20260928.test.ts @@ -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, /关闭\s*