Files
Jyotisha/docs/tasks/TASK-birth-sky-cover-20260928.md
T

266 lines
19 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.
# TASK · 「那一刻的天空」封面:星盘页入口、报告封面、首次建盘揭幕(2026-09-28)
## 基线
- `origin/staging` = `dc640931`(2026-09-28)。开工时 `git fetch origin --prune`,以最新 `origin/staging` 为基线,实际 SHA 写进 PROGRESS。
- 分支 `codex/birth-sky-cover-20260928`,工作树 `.worktrees/birth-sky-cover-20260928`。
- 进度记录 `docs/tasks/PROGRESS-birth-sky-cover-20260928.md`。
- 串行关系:**`TASK-serif-headings-20260928` 先做**(两单都动 `globals.css` 与 DESIGN.md §3);本单以它合入后的 staging 为基线。它若未合入,本单照写家族名,加载不到时自动退回黑体。`TASK-site-button-contrast-20260928`(BUG-1079)改全站按钮配色。它若已合入 staging,就以它为基线;若还没合入,本单新增的按钮一律用现有共享按钮类(`.button-secondary` / `<Button variant="outline">`),不自己写按钮颜色,这样两单没有文件交集。同日其它任务书与本单无交集。
- 视觉样稿:`docs/tasks/assets/birth-sky-cover-sample-20260928.html`,看第三张「C · 那一刻的天空」。A、B 两张是被否掉的方案,只作对照,不实现。
## 这是什么(产品背景,不是事故)
这是新功能,没有事故实证。产品看了竞品的「人设标签 + 几何徽章」分享海报,要求**不照抄**,做一张符合本站气质(私人阅览室、安静、诚实、不给人贴标签)的封面。三版样稿里产品选了 C:
> 出生地、出生那一刻头顶的真实天空。天顶在圆心、东在左,地平线以上的行星与亮星照实画,下方一句话。不讲运势,不贴标签。
## 现状实证(Claude 2026-09-28 读码,行号按符号定位)
| 事实 | 位置 |
| --- | --- |
| 星盘页标题栏已有 `actions?: ReactNode` 插槽,星盘页目前没传 | `SecondaryPageShell`(`frontend/src/components/secondary-page-shell.tsx`)→ `SecondaryHeader`;调用方 `ChartPageView` |
| 星盘页数据里有 `date`、`time`(本地 HH:MM)、`timezoneOffset`、`latitude`、`longitude`、`birthTimeStatus`,**没有 UTC 瞬间 / JD** | `chartViewProfileSchema`(`frontend/src/lib/chart-view-contract.ts`) |
| 看谁的盘由 `useCurrentSubject` 决定(localStorage,不在 URL);BFF 用 `?subject=` + `loadSubjectBirth` 在服务端取出生资料 | `lib/current-subject.ts`、`lib/subject-birth.ts`、`app/api/chart-view/route.ts` |
| 服务端「是否采用了校正时间」只在 `resolveServerOwnedChartBirth` 的 `adoption` 里,不下发客户端 | `lib/chart-birth-truth.ts` |
| Python 侧**没有任何** `azalt` / 赤经赤纬 / 恒星表代码,仓库里也没有 `sefstars.txt` | `scripts/` 全量 grep |
| 报告页没有封面:`SecondaryHeader` → `ReportActions` → 正文;`ReportEnvelopeView` 不带出生资料,DB 行有 `chart_profile_id`,但 `reportView` 没暴露 | `PersonalReportPage`、`app/(app)/reports/[reportId]/page.tsx`、`lib/personal-report-route-core.ts` |
| 首次建盘在首页 onboarding 里完成:`saveOnboardingPlace` 保存后留在 `/`,不跳转 | `hooks/use-profile-onboarding.ts`、`app/(app)/page.tsx` |
| 前端没有任何 SVG / DOM 转 PNG 的代码;唯一下载模式是 Blob + `a.download` | `lib/consultation-report-export.ts` `downloadMarkdownReport` |
| 可复用的弹层:原生 `<dialog>`,窄屏是底部抽屉,宽屏居中 | `ReportExportDrawer`、`ChartTypePicker`;DESIGN.md「报告导出抽屉」一节 |
| 行数余量:`page.tsx` 1370 / 上限 1567;`jyotish_api_server.py` 10935 / 上限 11224 | `home-shell-growth-contract.test.ts`、`tests/test_api_server_growth_contract.py` |
## 决策记录(产品 2026-09-28 授权)
1. **画面选 C**(真实天空),A 年轮、B 一笔画都不做。
2. **三个位置都做**,按顺序:① 星盘页入口 + 预览 + 保存图片;② 个人报告封面;③ 自己第一次建完盘时揭幕一次。
3. **不在图上印任何出生资料**:不印姓名、日期、时刻、经纬度、地名。天空本身会透露大致时段,产品确认可以接受。
4. **图上的那句话只描述天空,不讲运势**:不写大运、上升、性格。句子从下文「句库」按天空状态确定性地选出,同一张盘永远同一句,不调模型、不扣点。
5. **出生时间未采用校正的也能生成**:`birthTimeStatus` 不是 `accepted` / `confirmed` 时,图底加一行小字「按你填写的时间」。
6. **不另开页面,不加侧栏入口,不做第六个 Tab**;星盘页只在标题栏右侧加一个按钮。
7. **标题用宋体**(产品 2026-09-28 追加决定,见 `TASK-serif-headings-20260928`):封面主句与顶部小字用字体家族 `"Jyotisha Serif SC"`,由那份任务书自托管;事实句、底部说明仍用站内黑体栈。画布绘制前先 `await document.fonts.load('600 30px "Jyotisha Serif SC"', 主句)`,最多等 1.5 秒;超时就用黑体画,不出现等待态。**不引入 Google Fonts**。
8. **第一期不接社交平台分享 SDK**。保存图片走浏览器原生能力(见 T4)。
9. 罗睺、计都不是可见天体,**不画**。月亮在地平线下时不画,只进句库的事实句。
## 硬红线
1. `scripts/jyotish_api_server.py` 只做薄注册:`JyotishAPIHandler` 类方法数不得增长、`__new__` 伪造点不得增长;计算放新模块 `scripts/birth_sky.py`(AGENTS §6,由 `tests/test_api_server_growth_contract.py` 执行)。
2. `frontend/src/app/(app)/page.tsx`:`Home()` 的 `useState` / `useRef` 数不得增长;揭幕逻辑进 `hooks/` 或组件,由它们持有自己的状态,`page.tsx` 只接线(AGENTS §6,由 `home-shell-growth-contract.test.ts` 执行)。
3. **不新增 npm 依赖**。PNG 用原生 Canvas 2D 导出。
4. **封面代码全部懒加载**(`next/dynamic` 或动态 `import()`):`next build` 后 `/` 仍是 `○ Static`,首屏 gzip ±2%。
5. 出生资料只在服务端取(`loadSubjectBirth` / `resolveServerOwnedChartBirth`),**不接受客户端传来的日期、经纬度**,防止借接口给任意时刻地点算图。
6. 等待遵守 DESIGN §9:不写新 spinner 或骨架。星盘页进入后预取天空数据,点按钮时图已经就绪(见 T3);首次揭幕拿不到数据就静默跳过,不等待(见 T6)。
7. 恒星表只用公有领域来源(Yale Bright Star Catalogue 5th ed.,HR 编号),来源与许可写进数据文件头和 PROGRESS。不得从有版权的星图软件抓数据。
8. 不改数据库结构。一次性揭幕不需要「看过」标记(见 T6)。
9. 测试 fixture 来自真实引擎输出(golden),不手造形状(§7.4)。
10. 隐私:测试、PROGRESS、CHANGELOG 只用样稿里的虚构资料(1994-05-18 07:40 UTC+8,30.27N 120.15E)或公开名人资料。
## 任务分解
### T1 · 天空计算模块(Python)
新模块 `scripts/birth_sky.py`,提供纯函数 `compute_birth_sky(date, time, tz_offset_hours, lat, lon) -> dict`,在 `jyotish_api_server.py` 里薄注册为 `POST /api/birth_sky`。
- 瞬间:由本地日期时刻与 `tz_offset` 算 UT 与 JD。
- 行星:太阳、月亮、水、金、火、木、土的**真实高度角与方位角**(`swe.calc_ut` + `swe.azalt`,取几何高度 true altitude,不加大气折射;golden 按此口径)。方位角统一成「自正北起、向东为正」,**注意 swisseph 的方位角自正南起算**,要换算并在注释里写明。
- 恒星:`scripts/data/bright_stars.json` 收录 V 星等 ≤ 4.0 的恒星(HR 编号、J2000 赤经赤纬、星等),按 IAU 1976 岁差换到出生历元,再换算高度方位。只返回地平线以上的星。另附三组连线(北斗七星、仙后座 W、猎户座),用 HR 编号表示。
- 状态:`sunAltitude`,以及 `phase` ∈ `day`(太阳高度 > 0)/ `twilight_morning` / `twilight_evening`(−18° 到 0°,按上午或下午区分)/ `night`。
- 返回里**不带**输入的日期、时刻、经纬度。
**验收**
- `tests/test_birth_sky.py`:
1. 样稿虚构资料的 golden:太阳高度 31.7°±0.2°、方位 84.7°±0.3°(自北),土星高度 50.3°±0.2°、方位 191.1°±0.3°,月亮与木星在地平线下。
2. 独立校验:任意日期当地真太阳时正午,太阳高度 ≈ 90° − |纬度 − 赤纬|,误差 ±0.5°。
3. 北极星在北纬 30.27° 的高度约 30°(±1°)。
4. `phase` 四种状态各有一个用例。
- `tests/test_api_server_growth_contract.py` 通过。
- 快速门 `run_quality_gate.py --profile quick` 通过。
### T2 · BFF 路由与合同(前端服务端)
`frontend/src/app/api/birth-sky/route.ts`:`GET ?subject=<id>`。
- 服务端用与星盘页相同的方式解析这个 subject 的出生资料和 `adoption`。
- 调用 `/api/birth_sky`,返回 zod 合同 `birthSkyResponseSchema`(放在 `lib/birth-sky/contract.ts`):`phase`、`sunAltitude`、`planets[]`、`stars[]`、`lines[]`、`timeBasis: "adopted" | "reported"`。
- 资料不全时返回 `status: "birth_profile_incomplete"`,前端据此不显示入口。
- 不计费、不写库。
- 响应里**不带**出生资料原文。
**验收**
- `frontend/tests/birth-sky-route.test.ts`:
- 未登录返回 401;
- 别人的 subject 返回 404;
- 资料不全返回 incomplete;
- 正常返回符合合同,且序列化后的 JSON 不含 `latitude` / `longitude` / `date` / `time` / 地名字段。
### T3 · 封面渲染与星盘页入口(第一期)
- `lib/birth-sky/draw.ts` 提供一个**纯函数** `drawBirthSky(ctx, data, { palette, width, height, footnote })`,用 Canvas 2D 画整张 3:4 封面。预览和导出**共用这一个函数**,避免 SVG 转图片时网页字体丢失。
- 投影:天顶在圆心,r = (90 − 高度)/90 × R;北在上、**东在左**。
- 画面元素:
- 地平线圆,30° / 60° 虚线高度圈;
- 东南西北四个方位字;
- 恒星点,大小按星等;
- 三组连线;
- 行星用小圆点加中文名,太阳用赭色空心圆加光晕。
- 文字排版对照样稿 C:
- 顶部小字 `JYOTISHA · 那 一 刻`;
- 句库主句;
- 底部事实句;
- 最底 `仅供自我探索,不替你做决定。`;
- 需要时加「按你填写的时间」。
- 两套 palette:
- `night`:深色卡纸,屏幕用;
- `paper`:纸色底、墨色星点,报告打印用。
- 颜色取自 DESIGN.md 现有 token,不新增品牌色。
- `lib/birth-sky/sentence.ts`:句库选择,纯函数,见下表。**句子只能从这张表里出**,执行方不得自己加。
| 条件 | 主句 |
| --- | --- |
| `day` | 星星都在,只是还看不见。 |
| `twilight_morning` | 天快亮了,星星正一颗颗退场。 |
| `twilight_evening` | 天刚暗下来,星星正一颗颗出来。 |
| `night` | 那一刻,头顶就是这片天。 |
事实句最多两段,用「,」连接:
- 月亮:在地平线上时写「月亮在{八方位}」,在地平线下时写「月亮在脚下」。
- 除太阳外最高的一颗可见行星:写「{行星}正悬在{八方位}」。
- 八方位是:北、东北、东、东南、南、西南、西、西北。
- 白天另在事实句前加一段「太阳在{八方位}」。
- 按这些规则,样稿 C 那一刻应得「太阳在东,月亮在脚下,火星正悬在东南」。
VOICE.md 要求的开场问候不用「星星、月亮」一类比喻,这一条只管首页问候。封面写的是真实天体,不是比喻,所以不冲突。这四句定稿前请执行方对照 VOICE 的其余条款(短句、无 emoji),有冲突写进 PROGRESS,**不要自行改句**,由 Claude 验收时定。
- 组件 `components/birth-sky/birth-sky-dialog.tsx`:
- 复用 `ReportExportDrawer` 的原生 `<dialog>` 框架;窄屏占满,宽屏居中。
- 内容是一张按 3:4 等比缩放的 `<img>`(src 是导出的 PNG blob URL,手机上可长按保存),下方一个「保存图片」按钮。
- `ChartPageView` 往 `SecondaryPageShell` 的 `actions` 传一个按钮,文案「那一刻的天空」。
- 只在 `view.status === "ok"` 且天空数据就绪时出现。
- 星盘页 ok 后**立即预取** `/api/birth-sky`,完成后才显示按钮。预取失败就不显示按钮,不报错,也不占位。
- 按钮和弹层代码都懒加载。
- 切换人物(`useCurrentSubject` 变化)时按新 subject 重新预取。给家人的盘也能生成。
**验收**
- `frontend/tests/birth-sky-sentence.test.ts`:四种 phase 各一例,加事实句组合;样稿资料必须得到上面那句。
- `frontend/tests/birth-sky-draw.test.ts`:
- 用 mock canvas 记录 `fillText` 调用,断言画出的全部文字**不含**日期、时刻、经纬度、地名、姓名;
- 断言 `timeBasis: "reported"` 时出现「按你填写的时间」,`adopted` 时不出现;
- 断言东在左(方位 90° 的点 x < 圆心)。
- `frontend/tests/chart-page-view.test.tsx` 追加:数据就绪才出现按钮;资料不全或预取失败时无按钮。
- 导出尺寸是 1080×1440。
### T4 · 保存图片
- `lib/birth-sky/export.ts`:`canvas.toBlob("image/png")`,文件名 `jyotisha-sky.png`,不带人名和日期。
- 点「保存图片」:
- 若 `navigator.canShare?.({ files: [file] })` 为真,调 `navigator.share({ files })`,iOS 上会出现系统的「存储图像」;
- 否则走 Blob + `a.download`,与 `downloadMarkdownReport` 同一模式。
- 用户取消分享不算错误,不弹提示。
- 手机上长按预览图也能直接保存,这是 `<img>` 的原生行为。
**验收**:单测覆盖 share 可用、share 不可用、用户取消三条分支。真机项写进 `docs/testing/`(见 T7)。
### T5 · 报告封面(第二期)
- `reportView` 暴露报告对应的 subject(`chart_profile_id`,null 表示本人,对外映射成 `"self"`)。
- 报告页 ready 态在正文最上方加封面块:
- 屏幕上用 `night` palette,宽度随正文列、3:4 比例;
- 封面下方不加按钮,保存入口只在星盘页。
- **封面必须对得上报告**:只有当报告生成时的出生资料与现在的一致时才显示。执行方先查报告行或 `result_binding` 里是否存了出生资料的指纹:
- 有指纹:拿它和当前 `profileFingerprint` 比较,不一致就不显示封面;
- 找不到可靠的绑定:本单**不显示**封面,把缺口写进 PROGRESS 和 `BLOCKED.md`,**不要**拿当前资料冒充当时资料。
- 旧报告不补算、不补写库。没有封面时不留占位。
- 打印 / 保存 PDF:
- 封面改用 `paper` palette(DESIGN 规定打印固定浅色);
- 封面独占 A4 第一页(`break-after: page`);
- 不新增打印入口。
- 封面的取数在报告正文出现后进行,不阻塞正文。取数失败就不显示封面。
**验收**
- `frontend/tests/personal-report-view.test.ts` 追加:
- 指纹一致时有封面;
- 不一致或无绑定时无封面;
- 取数失败时无封面、正文照常。
- 打印样式的快照断言 `paper` palette 与分页。
### T6 · 首次建盘揭幕(第三期)
- 触发点:首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功之后)。它按定义每个账户只发生一次,所以**不需要「看过」标记**。
- 人物档案里新建家人不触发。
- 修改资料不触发。
- 保存成功后立即请求 `/api/birth-sky?subject=self`:
- **3 秒内**拿到数据,就在当前流程之上淡入同一个弹层。淡入 600ms,`prefers-reduced-motion` 时直接出现。
- 超时或失败就静默跳过,onboarding 照常往下走,不显示错误,不重试。
- 弹层内容与星盘页一致,另加一个「继续」按钮。
- 点「继续」、Esc、点遮罩都关闭弹层,回到原本的下一步(问候或校正引导),不改变下一步是什么。
- 若下一步会自动进入出生时间校正,弹层关闭后再进入。
- 逻辑放在新 hook `hooks/use-birth-sky-reveal.ts`(或组件内),`page.tsx` 只接线。
- 这一次揭幕属于用户操作后的结果展示,不是首页加载等待,不违反 DESIGN §9 的「首页只揭幕一次」。首页加载揭幕的轨道环逻辑**不动**。
**验收**
- `frontend/tests/profile-onboarding*.test.ts` 追加:
- 保存成功且 3 秒内有数据时出现弹层,关闭后进入原下一步;
- 超时时无弹层,流程照常;
- 家人档案不触发。
- `home-shell-growth-contract.test.ts` 通过。
- `next build` 后 `/` 仍是 `○ Static`,首屏 gzip ±2%。
### T7 · 文档
- `frontend/DESIGN.md`:新增一节「那一刻的天空」,写明:
- 投影与方位约定、两套 palette、三个位置;
- 预取规则、揭幕时限;
- 不印出生资料;
- 标题用 Jyotisha Serif SC。
- `frontend/docs/VOICE.md`:收录句库四句与事实句规则,并注明「只描述天空,不讲运势」。
- `CHANGELOG.md`:一条,写明 Skill 版本不 bump。
- `CONTEXT.md`:若新增「那一刻的天空 / 天空封面」术语,先进 glossary。
- `docs/testing/birth-sky-cover-checklist.md` 真机清单:
- iPhone Safari 预览图长按保存、「保存图片」弹系统分享;
- 安卓 Chrome 下载;
- 桌面 Chrome / Safari 下载;
- 家人档案生成;
- 报告封面屏幕显示与打印 PDF 第一页;
- 新账号首次建盘揭幕与跳过(可断网模拟)。
## 让步顺序(时间不够时从后往前砍)
1. 先砍 T6 首次揭幕。
2. 再砍 T5 报告封面。
3. T1~T4 + T7 是第一期,**不可砍**。
4. 猎户座、仙后座连线可以砍,只留北斗。
砍掉的部分写进 PROGRESS,不得标成完成。
## 开工前置命令
```bash
cd /workspace/Jyotisha && git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/birth-sky-cover-20260928 .worktrees/birth-sky-cover-20260928 origin/staging
cd .worktrees/birth-sky-cover-20260928
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 # 开工时最大号
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5 # 记下测试总数基线
```
Node 用 22(本机在 `/exec-daemon/node`)。
## BUG 编号
这是新功能,本身不开 BUG 条目。实现中若发现既有缺陷,从开工时 `docs/BUG_HISTORY.md` 最大号 +1 起编,写作时最大号是 **BUG-1079**。
## 交付
- 三期可以在同一分支分三个提交,顺序 T1–T4+T7 → T5 → T6。每个提交独立可测。
- 推 staging 前把 PROGRESS 写全:
- 基线 SHA;
- tsc / lint / 测试数(总数不低于基线);
- Python 定向测试与快速门结果;
- `/` Static 与 gzip 数字;
- 砍掉的项;
- 环境缺口。
- 用 `git push origin HEAD:staging` 快进推送,核对远端 SHA 与 `/api/health` 的 `deployment.gitCommit`。