fix(home): split starter fine print, show hints only when actionable (BUG-1041)

TASK-starter-home-polish-20260926:
- D1 today's trend moves to a sub-line under the greeting
  (.starter-greeting-sub), read from the same dailyStarlanguage state the
  warm snapshot seeds; static sentence until ready, hidden without a minute.
- D2 the line under the pills appears only for a resumable case or a
  non-self subject; the first-run and redo sentences are removed.
- BUG-1041 entrySummaryFromResponse read snake_case keys while the
  entry-summary route returns camelCase, so every account parsed as a
  first run; the parser now reads the route's shape (snake_case still ok).
- D3 daily entry icon MoonStar, credits icon Coins; D4 boundary line kept.
- Records: CHANGELOG, DESIGN, VOICE, BUG_HISTORY, PROGRESS, testing list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 08:47:53 +08:00
co-authored by Claude Opus 5.5
parent 6d371d82a7
commit b5a02ca861
16 changed files with 427 additions and 68 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-26 — 首页开场小字拆开,今日星语和点数换图标(待验收)
- 今日趋势(今日星语卡片那一句,每天生成)挪到问候语下面单独一行,浅色、最多两行;还没生成时写「今天的星语还没写出来。」,没有出生分钟时不显示。
- 入口按钮下面平时不再有字,只在需要动手时出一句:有没做完的校正写「上次那次校正还没完成,可以接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。删掉「不确定出生时间时…」「上次已经校正完…」两句。
- 修好已校正过的账户仍被当成第一次的问题(BUG-1041):首页读校正状态时一直读错字段名,所以从没出现过「再次校正」和未完成提示。
- 今日星语入口图标换成带星的月亮,右上角点数换成硬币,两处不再是同一个图标;读屏名称不变。
- Skill 版本不 bump。不改接口、今日星语生成逻辑和数据库;登录态真机与部署待验收。
## 2026-09-26 — 从其他页面回首页不再重放加载动画(待验收)
- 同一次打开网页里,从星盘、星历、我的报告、星盘档案点「新建对话」、点侧栏里的历史对话、点左下角头像或按浏览器返回回到首页,不再出现「正在载入账户 / 正在准备对话」加载环,首页立刻可以输入。
+15
View File
@@ -13928,3 +13928,18 @@
- 相关记录:BUG-966(次级页模块缓存先例)、BUG-479(一次等待一次揭幕)、BUG-1021(不半揭幕,快照缺项回冷启动)、BUG-1038(新建意图与存根规则,暖路径沿用)、BUG-936(首屏兜底,`data-hydrated` 不变)。
- 复发自:无。
- 修复版本:`codex/home-warm-return-20260926`,未部署。
## BUG-1041 | 首页校正入口摘要解析读错键名:已校正账户仍显示首次文案、未完成的校正从不提示
- 状态:investigating(已修复待部署;登录态真机未复测)
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
- 影响面:`frontend/src/lib/rectification-entry.ts` `entrySummaryFromResponse`(首页 `page.tsx` 启动 effect 与 `useRectificationSurface.refreshRectificationEntrySummary` 共用),进而影响首页生时校正入口的可访问名(`开始新的生时校正` / `再次校正`)与入口下方提示。
- 用户现象:产品 09-26 真机截图:账户已经校正过,首页入口下方仍写首次文案「不确定出生时间时,用记得住的经历一步步缩小范围」;按钮可访问名也一直是「开始新的生时校正」,从未出现「再次校正」。
- 触发条件:任何有未完成或已完成校正 Case 的账户打开首页。
- 根因:`GET /api/rectification/cases/entry-summary` 返回 `getRectificationEntrySummary` 投影后的 **camelCase** 对象(`hasResumableCase` / `hasTerminalCaseWithTime` / `latestTerminal.caseId`…),只有底下的 RPC 行是 snake_case;而浏览器端 `entrySummaryFromResponse` 只读 snake_case 键(`has_resumable_case` 等),所以每一个真实响应都被解析成「什么都没有」。`resolveRectificationEntryAction` 恒为 `start`,`rectificationCardAction === "restart"` 与 `hasResumableCase` 分支从未命中。自 2026-08-11 `ca748466`(BUG-163 入口改造)起存在。测试没拦住:`rectification-v9-entry-routing.test.ts`、`home-warm-return-lifecycle.test.tsx`、`new-chat-from-people-lifecycle.test.tsx` 的 entry-summary mock 都是手写的 snake_case,不是路由真实输出;`rectification-v9-case-service.test.ts` 只测到服务端投影为止,两段之间没有往返合同。
- 修复:`entrySummaryFromResponse` 以路由真实的 camelCase 形状为准,同时仍读 snake_case(RPC 形状不会再静默塌成空)。首页小字按 TASK-starter-home-polish-20260926 D2 改为只读 summary 的 `hasResumableCase` 与当前人物,不再依赖派生的 `restart`。
- 验证:`frontend/tests/starter-home-polish-20260926.test.tsx`「BUG-1041: the homepage parser reads what the entry-summary route actually returns」:用 `getRectificationEntrySummary`(假 RPC)产出、`JSON` 往返后交给 `entrySummaryFromResponse`,断言 `hasTerminalCaseWithTime`、`latestTerminal`、`restart` 与未完成提示;修复前该条失败(解析结果全 false)。本地 `next start` + Chrome 151,`/api/*` 以 camelCase 形状回放:已校正账户可访问名「再次校正」、无小字;未完成账户显示「上次那次校正还没完成,可以接着做。」。全量 3960 条 / 61 失败,失败名单与基线逐条一致。
- 防复发:往返合同测试锁住「路由输出 → 首页解析」一段;新的首页测试 fixture 用路由真实的 camelCase 形状。旧的 snake_case mock 仍能被解析,未改动。
- 相关记录:BUG-163(入口摘要由服务端 Case 驱动)、BUG-1040(暖快照缓存的正是解析后的 summary,快照结构不变)。
- 复发自:无。
- 修复版本:`codex/starter-home-polish-20260926`,未部署。
@@ -0,0 +1,69 @@
# PROGRESS · 首页开场小字拆分 + 今日星语 / 点数图标(2026-09-26)
任务书:`TASK-starter-home-polish-20260926.md`。执行方:Claude 子代理(直接执行模式)。分支 `codex/starter-home-polish-20260926`,worktree `.worktrees/starter-home-polish-20260926`,本地提交未推送。
## 基线
- `origin/staging` = `7475eb6e`(BUG-1038、BUG-1040 已合入)。
- 基线全量前端测试(Node 20.19.2):3953 条,pass 3865 / fail 61 / skipped 27(61 条均为环境缺口:Node 20 `mock.module` 的 `ERR_MODULE_NOT_FOUND @/…`、无 Docker)。
- 基线 `next build --webpack`:`/` ○ Static;rootMainFiles gzip 130933 B;`index.html` 引用的全部 js/css gzip 合计 656646 B。
- BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大 BUG-1040,远端各分支无更大号 → 本单用 BUG-1041。
## 改动
| 任务 | 文件 | 内容 |
| --- | --- | --- |
| T1 / D1 | `components/starter-home.tsx`、`app/(app)/page.tsx`、`globals.css` | `StarterGreeting` 加可选 `dailyTrend` / `dailyTrendSettled`,渲染 `.starter-greeting-sub`(`--color-ink-secondary`、`--type-body-sm`、两行截断)。`page.tsx` 传 `natalMinuteAvailable ? dailyStarlanguageTrend : null`,读的就是 BUG-1040 暖快照初始化的同一个 `dailyStarlanguage` state;未就绪沿用静态句「今天的星语还没写出来。」+ `role="status"`,无 `aria-busy`、无新等待态。 |
| T2 / D2 | `components/starter-home.tsx` | 新增纯函数 `rectificationEntryHint(forSelf, summary)`:非本人 →「生时校正暂时只支持本人。」;`summary.hasResumableCase` →「上次那次校正还没完成,可以接着做。」;其余 `null`,不渲染 `<p>`。删「不确定出生时间时…」「上次已经校正完…」「未完成的那次可以从左侧继续」「看看今天的整体节奏」四句与「·」拼接。`StarterEntries` 去掉不再用的 `dailyStarlanguage` / `dailyStarlanguageTrend` / `rectificationCardAction` 三个 prop。 |
| T2 / 分支误判 | `lib/rectification-entry.ts` | **真 bug(BUG-1041)**:`/api/rectification/cases/entry-summary` 返回 camelCase(`getRectificationEntrySummary` 的投影),`entrySummaryFromResponse` 只读 snake_case,每个真实响应都解析成全 false。改为优先读 camelCase、仍兼容 snake_case。结果:已校正账户可访问名变回「再次校正」,未完成提示能出现。 |
| T2 / D4 | — | 边界句「出生时间不足以支持的判断我会说明,不会补造具体分钟。」原位保留。 |
| T3 / D3 | `components/starter-home.tsx`、`app/(app)/page.tsx` | 今日星语入口 `Sparkles` → `MoonStar`;右上角点数 `Sparkles` → `Coins`(`className="credit-icon"`、`aria-hidden`、按钮 `aria-label` 不变)。已确认 lucide-react 1.24.0 同时导出 `MoonStar`、`Coins`。 |
| T4 | `CHANGELOG.md`、`frontend/DESIGN.md`(Starter home + §9)、`frontend/docs/VOICE.md`、`docs/BUG_HISTORY.md`(BUG-1041, investigating)、`docs/testing/starter-home-polish-20260926.md`、`docs/tasks/README.md` | 记录。 |
红线核对:`Home()` 未新增 hook(只改 JSX 表达式);`page.tsx` 1373 → 1370 行;无语句压行;未改今日星语接口与生成逻辑;未改依赖、迁移、workflow。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error(126 warning,均为既有文件的既有 warning,本单文件无新增) |
| 全量 `npm test` | 3960 条,pass 3872 / fail 61 / skipped 27 |
| 失败名单 vs 基线 | 逐条一致(`diff` 为空),新增失败 0 |
| 测试名单 vs 基线 | +7 条新名(下文),消失 0 条 |
| Python 源码合同 | `tests/test_daily_and_rectification_entrypoints.py`、`test_session_management_entrypoints.py`、`test_birth_time_journey_contract.py`、`test_supabase_user_data_contract.py` 全过 |
| `npm run build -- --webpack` | 通过;`/` ○ Static,`/chart` `/ephemeris` `/people` ○ |
| gzip | rootMainFiles 130933 → 130933 B(0);首屏引用全部 js/css 656646 → 656729 B(+83 B,+0.013%) |
新增 `frontend/tests/starter-home-polish-20260926.test.tsx`(7 条):D2 首次 / 已校正无提示、未完成一句;D2 非本人(纯函数 + 真实 subject store 渲染,按钮禁用);D4 边界句;D1 副行三态(就绪 / 静态句 + status / 无分钟不渲染);D1 page.tsx 读暖快照同源 state;D3 图标与可访问名;BUG-1041 路由输出 JSON 往返后解析(修复前此条失败,已实测)。
### 改动既有断言(三栏同时写在测试文件内)
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/icon-motion-audit.test.ts` | `import { Clock3, Sparkles }`、`<Sparkles aria-hidden="true" />`、doesNotMatch `/Moon\|MoonStar\|thinking-orbs/` | `import { Clock3, MoonStar }`、`<MoonStar aria-hidden="true" />`、doesNotMatch `/Sparkles\|thinking-orbs/` | D3 产品拍板换 MoonStar;「无循环动画、无 thinking-orbs」原样保留,并反向锁住旧图标 |
| `frontend/tests/rectification-agentic-entry.test.ts` | match `/rectificationCardAction === "restart"/` | match `rectificationEntryHint(rectificationForSelf, rectificationEntrySummary)` 与 `summary?.hasResumableCase`;doesNotMatch `=== "restart"` | D2 删掉 restart 那句;BUG-1041 查明该分支从未命中。「由服务端 entry summary 驱动、不读会话列表」的意图不变 |
## 浏览器验证(本地 `next start` + Chrome 151 headless,CDP 拦截 `/api/*` 回放虚构数据,entry-summary 用路由真实的 camelCase 形状)
| 场景 | 副行 | 入口下方 | 其他 |
| --- | --- | --- | --- |
| 375 / 1280 × 明 / 暗,已校正账户 | 趋势句两行,明 `rgb(95,95,89)` / 暗 `rgb(179,175,164)`,14px | 无 | 生时校正可访问名「再次校正」;图标 moon-star / coins;无横向溢出 |
| 375 明,未完成校正 | 趋势句 | 「上次那次校正还没完成,可以接着做。」 | 可访问名「开始新的生时校正」 |
| 375 明,首次 | 趋势句 | 无 | — |
| 1280 明,今日星语 unavailable | 「今天的星语还没写出来。」`role="status"` | 无 | — |
| 375 明,无出生分钟 | 不渲染 | 仅边界句 | 入口「查看今日运势」 |
| 暖返回:首页 → /chart → 新建对话(今日星语接口压 2.5 s) | 首帧即有趋势句(点击后 17–20 ms) | — | 可交互 23–27 ms,加载环 0 次;控制台 0 error |
截图(仓库内):`docs/testing/starter-home-polish-20260926/starter-375-light-resumable.png`、`starter-1280-dark.png`。其余截图在会话 scratchpad `shp/`(375/1280 明暗、静态句、无分钟、暖返回),未入库。
## 环境缺口
- 无登录态真机:真实账户的 entry-summary、真实今日星语文本长度、VoiceOver 朗读 → `docs/testing/starter-home-polish-20260926.md`。
- Node 20:61 条 `mock.module` 类失败为既有环境缺口,名单与基线一致。
- 无 Docker:DB 套件未跑(本单不动表)。
## 观察(未处理,留给产品 / Claude 定夺)
- 首页「生时校正」按钮走 `intent=homepage`,服务端总是**新建** Case;未完成的那次要从侧栏继续。产品指定的新文案「上次那次校正还没完成,可以接着做。」没有说「从左侧」,用户可能以为点按钮就会接着做。按任务书原文实现,未改。
- `useRectificationSurface.rectificationLimitedToSelf` 点击时的错误文案仍是无句号的「生时校正暂时只支持本人」(非本人时按钮已禁用,正常点不到),不在本单范围。
+1 -1
View File
@@ -119,7 +119,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-starter-home-polish-20260926.md` | — | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后 | 待领取 | — |
| `TASK-starter-home-polish-20260926.md` | `PROGRESS-starter-home-polish-20260926.md` | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后。分支误判实为入口摘要解析读错键名(BUG-1041) | 待验收(Claude 09-26 直接执行:子代理实现,tsc/lint 0、全量 3960 条 / 61 失败与基线逐条一致、`/` ○、首屏 gzip +0.01%;CDP 375/1280 明暗截图) | `codex/starter-home-polish-20260926`(本地未推) |
| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 已验收(Claude 09-26 直接执行:子代理实现,Claude 独立复验 tsc/lint 0、全量 3953 条失败名单与基线逐条一致、四路由 ○、gzip 不变;CDP 实测回首页 5.5 s → ~20 ms、无加载环) | `638b6a60`(已部署 `7475eb6e`,health 一致) |
| `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已验收(Claude 09-26 直接执行:子代理复现并定位 sessionStorage 登录返回存根;Claude 独立复验 tsc/lint 0、全量 3928 条失败名单与基线逐条一致、四路由 ○、gzip 不变、修复前后截图核对) | `0a8350cc`(已部署 `6a22626d`,health 一致) |
| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) |
@@ -0,0 +1,11 @@
# 首页开场小字与图标 · 真机清单(2026-09-26)
本轮在本地 `next start` + 无头 Chrome 里用虚构接口数据截过图(`starter-home-polish-20260926/`),没有登录态真机。下面留给真人,用自己的账户在 staging 上照做:
1. 打开首页:问候语下面一行浅色小字是今天的趋势句,最多两行;入口按钮(今日星语、生时校正)下面没有字。
2. 如果你有一次没做完的生时校正:入口按钮下面只有一句「上次那次校正还没完成,可以接着做。」。
3. 如果你已经校正完、没有没做完的:按钮下面没有字;用读屏(VoiceOver)读生时校正按钮,应读「再次校正」。
4. 在星盘档案里切到另一个人再回首页:按钮下面只有「生时校正暂时只支持本人。」,生时校正按钮是灰的。
5. 今日星语按钮是带星的月亮图标,右上角点数是硬币图标,两处不一样。
6. 从星盘页点「新建对话」回首页:没有加载环,问候语下面的趋势句一出来就在,不会先空着再跳出来。
7. 375px 宽(手机)明暗两种主题下:趋势句不溢出、不遮挡输入框;深色下小字能看清。
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+28 -11
View File
@@ -533,7 +533,7 @@ The empty state is the composer. It used to be a bordered hero card at
sitting on top of the input, so the primary action was the last thing on the
page. Three parts now, in reading order:
- **Greeting** (`.starter-greeting-block`): one line, no subtitle — a single h1
- **Greeting** (`.starter-greeting-block`): one line — a single h1
at `clamp(24px, 2.8vw, 30px)`, weight 500, `line-height: 1.25`, centred. The
salutation line above the question is gone; so is the question, which is the
composer placeholder's job now («想聊什么都可以»). The line is drawn per visit
@@ -543,6 +543,14 @@ page. Three parts now, in reading order:
`{名字}` entries dropped when no name is set. It lives in the conversation area
with `align-content: end`, so it sits directly above the composer rather than
floating in the middle of the row.
- **Daily sub-line** (`.starter-greeting-sub`, 2026-09-26): today's trend sits
under the greeting as one quiet paragraph — `--color-ink-secondary`,
`--type-body-sm`, `line-height: 1.55`, `max-width: 560px`, clamped to two
lines with an ellipsis. It reads the same `dailyStarlanguage` state the warm
snapshot seeds (BUG-1040), so a warm return shows it on the first frame. Until
the card is ready it shows the existing static sentence「今天的星语还没写出来。」
with `role="status"` and no `aria-busy`, spinner or placeholder; the reveal
rule does not change. Without a natal minute it is not rendered at all.
- **Composer**: unchanged, and *in the same DOM position it occupies during a
live session*. Nothing remounts when the first message lands, so an in-flight
draft, focus, or queued card survives the transition. Its width is
@@ -552,9 +560,14 @@ page. Three parts now, in reading order:
- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside
`.composer-wrap` *below* the composer. The visual pill is 36px tall
(`min-height: 36px`). The transparent hit area is the `::after` box with
`inset: -4px`, so the touch target is 44px. One word each, accent icon 15px.
Below them one line merges the daily trend with the rectification hint, and
an accuracy-boundary line appears when no natal minute is available.
`inset: -4px`, so the touch target is 44px. One word each, accent icon 15px:
lucide `MoonStar` for 今日星语 (was `Sparkles`, the same glyph as the credits
button) and `Clock3` for 生时校正. The header credits button uses lucide
`Coins`; its accessible name is unchanged. Below the pills there is normally
**nothing**: a hint line (`.starter-entry-hint`) appears only when the user has
something to do, and an accuracy-boundary line appears when no natal minute is
available. The daily trend and the rectification hint are no longer joined
with「·」.
- **States, measured:**
- Default border is `1px solid var(--starter-entry-border)`, and that token is
@@ -585,15 +598,19 @@ page. Three parts now, in reading order:
entrypoint identifier and expanded only on the server. There is no
“从一个主题开始” grid; leftover `guided_topic` values in stored sessions still
pin domains when replayed.
- **Rectification hint** keeps three server-driven branches — an unfinished case,
a finished one that can be redone (`rectificationCardAction === "restart"`),
and a first run. The branch comes from the entry summary, never the session
list.
- **Rectification hint** (`rectificationEntryHint`, 2026-09-26) has two
visible branches and reads the entry summary itself, never the session list
and never the derived start/restart label: a non-self current person →
「生时校正暂时只支持本人。」; otherwise `hasResumableCase` →
「上次那次校正还没完成,可以接着做。」. First runs and finished cases show no
hint. The old first-run and "redo" sentences are gone. (The summary parser had
been reading the wrong key casing since 2026-08-11, so every account looked
like a first run — BUG-1041.)
- **Responsive:** the pill row wraps and stays centred; there is no
phone-specific layout, because there is no fixed-height card to collapse.
- **Accessibility:** each pill is one native button with a descriptive accessible
name (the full action label, not the one-word face). The daily hint keeps
`role="status"` while the trend is still arriving.
name (the full action label, not the one-word face). The daily sub-line keeps
`role="status"` while the trend has not arrived.
### Account popover
@@ -703,7 +720,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、登录返回存根与其人物范围同 BUG-1038),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 首屏死屏兜底
+3 -1
View File
@@ -18,6 +18,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
## 星盘空状态、0 年大运、星历切日期
这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」:
@@ -177,7 +179,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。
星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人」。删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
+3 -6
View File
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { loadBillingPanel } from "@/lib/billing-panel-loader";
import { Sparkles } from "lucide-react";
import { Coins } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
@@ -1210,7 +1210,7 @@ export default function Home() {
<div className="chat-header-actions">
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
<button className="credit-button" type="button" onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<Coins className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
</div>
@@ -1244,7 +1244,7 @@ export default function Home() {
) : null}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
<StarterGreeting starterGreeting={starterGreeting} />
<StarterGreeting starterGreeting={starterGreeting} dailyTrend={natalMinuteAvailable ? dailyStarlanguageTrend : null} dailyTrendSettled={dailyStarlanguage.kind === "ready"} />
)}
</div>
) : sessionMessagesLoading ? (
@@ -1328,13 +1328,10 @@ export default function Home() {
{starterHomeVisible && (
<StarterEntries
natalMinuteAvailable={natalMinuteAvailable}
dailyStarlanguage={dailyStarlanguage}
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
dailyStarlanguageTrend={dailyStarlanguageTrend}
productEntrypointsDisabled={productEntrypointsDisabled}
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
rectificationCardLabel={rectificationCardLabel}
rectificationCardAction={rectificationCardAction}
rectificationLoading={rectificationLoading}
rectificationMutationPending={rectificationMutationPending}
openRectificationFromHomepage={openRectificationFromHomepage}
+17
View File
@@ -2258,6 +2258,23 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
text-wrap: balance;
}
/* Today's trend, one quiet line under the greeting (2026-09-26). It used to be
glued to the rectification hint under the pills with a middle dot. Two lines
at most: the trend is model-written and can run long on a phone. */
.starter-greeting-sub {
display: -webkit-box;
max-width: 560px;
margin: var(--space-2) auto 0;
overflow: hidden;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
text-wrap: pretty;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* Entry pills, under the composer. The two product entrances keep their place
in the product but not their weight: they were 132px cards competing with the
input for the first look. */
+32 -31
View File
@@ -1,8 +1,7 @@
"use client";
import { Clock3, Sparkles } from "lucide-react";
import { Clock3, MoonStar } from "lucide-react";
import { useCurrentSubject } from "@/lib/current-subject";
import type { DailyStarlanguageState } from "@/lib/home-types";
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
/**
@@ -21,27 +20,50 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry";
export type StarterGreetingProps = {
readonly starterGreeting: { readonly text: string };
/* Today's trend, one quiet line under the greeting. `null` hides it (no natal
minute). Before the card is ready the caller passes the static sentence,
never a placeholder or a spinner. */
readonly dailyTrend?: string | null;
readonly dailyTrendSettled?: boolean;
};
/* One line. The salutation/question pair it replaces asked the user's question
for them; the composer placeholder asks now. */
export function StarterGreeting({ starterGreeting }: StarterGreetingProps) {
for them; the composer placeholder asks now. The optional sub-line is today's
trend, moved up from the fine print under the entry pills where it was glued
to an unrelated rectification hint with a middle dot. */
export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrendSettled = true }: StarterGreetingProps) {
return (
<section className="starter-greeting-block" aria-labelledby="starter-heading">
<h1 id="starter-heading">{starterGreeting.text}</h1>
{dailyTrend ? (
<p className="starter-greeting-sub" role={dailyTrendSettled ? undefined : "status"}>
{dailyTrend}
</p>
) : null}
</section>
);
}
/* The line under the pills only appears when there is something to do about
it. First runs and finished cases say nothing: the pill already names the
action. The branch reads the entry summary itself, not the derived
start/restart label, and a non-self subject wins because the case summary
belongs to the account owner. */
export function rectificationEntryHint(
rectificationForSelf: boolean,
summary: RectificationEntrySummary | null,
): string | null {
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
if (summary?.hasResumableCase) return "上次那次校正还没完成,可以接着做。";
return null;
}
export type StarterEntriesProps = {
readonly dailyStarlanguage: DailyStarlanguageState;
readonly natalMinuteAvailable: boolean;
readonly dailyStarlanguageQuestion: string;
readonly dailyStarlanguageTrend: string;
readonly productEntrypointsDisabled: boolean;
readonly startDailyStarlanguageConsultation: () => void;
readonly rectificationCardLabel: string;
readonly rectificationCardAction: string;
readonly rectificationLoading: boolean;
readonly rectificationMutationPending: boolean;
readonly openRectificationFromHomepage: () => void;
@@ -53,13 +75,10 @@ export type StarterEntriesProps = {
export function StarterEntries({
natalMinuteAvailable,
dailyStarlanguage,
dailyStarlanguageQuestion,
dailyStarlanguageTrend,
productEntrypointsDisabled,
startDailyStarlanguageConsultation,
rectificationCardLabel,
rectificationCardAction,
rectificationLoading,
rectificationMutationPending,
openRectificationFromHomepage,
@@ -71,20 +90,7 @@ export function StarterEntries({
const subjectId = useCurrentSubject();
const rectificationForSelf = subjectId === "self";
const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势";
/* The trend line is the only part that arrives late. It stays a live region so
a screen reader hears it settle, but it is a tooltip-rank hint now, not a
card body — the pill is actionable from the first paint either way. */
const dailyHint = natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏";
/* Three branches, same as the card it replaces: an unfinished case, a finished
one that can be redone, and a first run. The label itself is server-driven
from the entry summary (`rectificationCardAction`), not from the session list. */
const rectificationHint = !rectificationForSelf
? "生时校正暂时只支持本人"
: rectificationEntrySummary?.hasResumableCase
? "未完成的那次可以从左侧继续"
: rectificationCardAction === "restart"
? "上次已经校正完,可以拿最新资料再来一次"
: "不确定出生时间时,用记得住的经历一步步缩小范围";
const rectificationHint = rectificationEntryHint(rectificationForSelf, rectificationEntrySummary);
return (
<div className="starter-entries">
@@ -96,7 +102,7 @@ export function StarterEntries({
disabled={productEntrypointsDisabled}
onClick={startDailyStarlanguageConsultation}
>
<Sparkles aria-hidden="true" />
<MoonStar aria-hidden="true" />
<span>{dailyLabel}</span>
</button>
<button
@@ -111,12 +117,7 @@ export function StarterEntries({
<span>生时校正</span>
</button>
</div>
<p
className="starter-entry-hint"
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>
{dailyHint} · {rectificationHint}
</p>
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
{!natalMinuteAvailable && (
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
)}
+27 -14
View File
@@ -69,32 +69,45 @@ export function isTerminalRectificationStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}
/* `GET /api/rectification/cases/entry-summary` answers with the camelCase
object `getRectificationEntrySummary` returns; only the RPC underneath speaks
snake_case. This parser used to read the snake_case keys alone, so every real
response parsed as "nothing on record" and the homepage never saw a resumable
or finished case (BUG-1041). The route's camelCase shape wins; snake_case is
still read so an RPC-shaped payload does not silently collapse either. */
function field(row: Record<string, unknown>, camel: string, snake: string): unknown {
return camel in row ? row[camel] : row[snake];
}
function objectField(row: Record<string, unknown>, camel: string, snake: string): Record<string, unknown> | null {
const value = field(row, camel, snake);
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
if (!value || typeof value !== "object") {
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
}
const row = value as Record<string, unknown>;
const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object"
? row.latest_resumable as Record<string, unknown>
: null;
const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object"
? row.latest_terminal as Record<string, unknown>
: null;
const latestResumable = objectField(row, "latestResumable", "latest_resumable");
const latestTerminal = objectField(row, "latestTerminal", "latest_terminal");
const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null;
const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null;
return {
hasResumableCase: row.has_resumable_case === true,
hasTerminalCaseWithTime: row.has_terminal_case_with_time === true,
latestResumable: latestResumable && typeof latestResumable.case_id === "string"
hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true,
hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true,
latestResumable: latestResumable && typeof resumableCaseId === "string"
? {
caseId: latestResumable.case_id,
caseId: resumableCaseId,
status: String(latestResumable.status ?? ""),
lastActivityAt: String(latestResumable.last_activity_at ?? ""),
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
}
: null,
latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string"
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
? {
caseId: latestTerminal.case_id,
caseId: terminalCaseId,
status: String(latestTerminal.status ?? ""),
hasUsableTime: latestTerminal.has_usable_time === true,
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
}
: null,
};
+7 -3
View File
@@ -6,10 +6,14 @@ const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", i
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("the 2026-09-22 icon audit keeps the existing entry icons and adds no loop", () => {
assert.match(starterHome, /import \{ Clock3, Sparkles \} from "lucide-react"/);
assert.match(starterHome, /<Sparkles aria-hidden="true" \/>/);
// 原值:`import { Clock3, Sparkles }`、`<Sparkles aria-hidden="true" />`,且 doesNotMatch /Moon|MoonStar|thinking-orbs/
// 新值:`import { Clock3, MoonStar }`、`<MoonStar aria-hidden="true" />`,doesNotMatch 收窄为 /Sparkles|thinking-orbs/
// 原因:TASK-starter-home-polish-20260926 D3 产品拍板今日星语入口改 lucide MoonStar(与右上角点数不再同图标);
// 「不加循环动画、不引入 thinking-orbs」这条意图原样保留,并反向锁住旧图标不回来。
assert.match(starterHome, /import \{ Clock3, MoonStar \} from "lucide-react"/);
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
assert.match(starterHome, /<Clock3 aria-hidden="true" \/>/);
assert.doesNotMatch(starterHome, /Moon|MoonStar|thinking-orbs/);
assert.doesNotMatch(starterHome, /Sparkles|thinking-orbs/);
const blockStart = css.indexOf(".starter-entry {");
const blockEnd = css.indexOf(".starter-entry > svg");
const block = css.slice(blockStart, blockEnd);
@@ -919,7 +919,14 @@ test("homepage CTA is server-driven from the entry summary, not the session list
assert.doesNotMatch(page, /hasRectificationSession/);
assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/);
assert.match(page, /resolveRectificationEntryAction/);
assert.match(page, /rectificationCardAction === "restart"/);
// 原值:assert.match(page, /rectificationCardAction === "restart"/)
// 新值:首页小字改由 rectificationEntryHint 直接读 entry summary 的 hasResumableCase;
// 「restart」分支对应的那句已删除,改锁 hint 读的是 summary 本身、不读会话列表
// 原因:TASK-starter-home-polish-20260926 D2 删掉「上次已经校正完…」一句;BUG-1041 查明
// `=== "restart"` 从未命中(解析器读错键名)。「CTA 与提示由服务端 entry summary 驱动」这条意图不变。
assert.match(page, /rectificationEntryHint\(rectificationForSelf, rectificationEntrySummary\)/);
assert.match(page, /summary\?\.hasResumableCase/);
assert.doesNotMatch(page, /rectificationCardAction === "restart"/);
assert.match(entryLib, /开始新的生时校正/);
assert.match(entryLib, /再次校正/);
});
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
StarterEntries,
StarterGreeting,
rectificationEntryHint,
type StarterEntriesProps,
} from "../src/components/starter-home.tsx";
import { setCurrentSubject } from "../src/lib/current-subject.ts";
import { getRectificationEntrySummary } from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
entrySummaryFromResponse,
resolveRectificationEntryAction,
type RectificationEntrySummary,
} from "../src/lib/rectification-entry.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const CASE_ID = "00000000-0000-4000-8000-000000000041";
const RESUMABLE_HINT = "上次那次校正还没完成,可以接着做。";
const NON_SELF_HINT = "生时校正暂时只支持本人。";
const REMOVED_HINTS = /不确定出生时间时|上次已经校正完|未完成的那次可以从左侧继续|看看今天的整体节奏| · /;
const summaries = {
firstRun: { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null },
rectified: {
hasResumableCase: false,
hasTerminalCaseWithTime: true,
latestResumable: null,
latestTerminal: { caseId: CASE_ID, status: "confirmed", hasUsableTime: true },
},
resumable: {
hasResumableCase: true,
hasTerminalCaseWithTime: true,
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
latestTerminal: null,
},
} satisfies Record<string, RectificationEntrySummary>;
function entriesProps(overrides: Partial<StarterEntriesProps> = {}): StarterEntriesProps {
return {
natalMinuteAvailable: true,
dailyStarlanguageQuestion: "深入看今日",
productEntrypointsDisabled: false,
startDailyStarlanguageConsultation: () => undefined,
rectificationCardLabel: "开始新的生时校正",
rectificationLoading: false,
rectificationMutationPending: false,
openRectificationFromHomepage: () => undefined,
rectificationEntrySummary: null,
rectificationError: "",
rectificationSurfaceOpen: false,
rectificationErrorMessage: "",
...overrides,
};
}
function hintOf(markup: string): string | null {
return markup.match(/<p class="starter-entry-hint">([^<]*)<\/p>/)?.[1] ?? null;
}
test("D2: the line under the pills only appears when the user has something to do", () => {
// First run and an already-rectified account: no hint at all.
for (const summary of [null, summaries.firstRun, summaries.rectified]) {
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summary })} />);
assert.equal(hintOf(markup), null, JSON.stringify(summary));
assert.doesNotMatch(markup, /starter-entry-hint/);
assert.doesNotMatch(markup, REMOVED_HINTS);
}
// An unfinished case: one sentence, the resumable one.
const resumable = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
assert.equal(hintOf(resumable), RESUMABLE_HINT);
assert.doesNotMatch(resumable, REMOVED_HINTS);
});
test("D2: a non-self subject gets the self-only sentence, even with an unfinished case on record", async () => {
assert.equal(rectificationEntryHint(false, null), NON_SELF_HINT);
assert.equal(rectificationEntryHint(false, summaries.resumable), NON_SELF_HINT);
assert.equal(rectificationEntryHint(true, summaries.resumable), RESUMABLE_HINT);
assert.equal(rectificationEntryHint(true, summaries.rectified), null);
assert.equal(rectificationEntryHint(true, summaries.firstRun), null);
assert.equal(rectificationEntryHint(true, null), null);
// Rendered through the real subject store, not just the helper.
const harness = createClientLifecycleHarness();
try {
setCurrentSubject("person-fictional-1");
await harness.render(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
const hints = harness.elements().filter((node) => node.getAttribute("class") === "starter-entry-hint");
assert.equal(hints.length, 1);
assert.equal(hints[0].text, NON_SELF_HINT);
const rectificationPill = harness.elements().find((node) => node.getAttribute("aria-label") === "开始新的生时校正");
assert.equal(rectificationPill?.disabled, true);
} finally {
setCurrentSubject("self");
await harness.close();
}
assert.deepEqual(harness.errors, []);
});
test("D4: the no-minute boundary sentence stays under the pills", () => {
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ natalMinuteAvailable: false })} />);
assert.match(markup, /<p class="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。<\/p>/);
assert.doesNotMatch(markup, REMOVED_HINTS);
const withMinute = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
assert.doesNotMatch(withMinute, /starter-entry-boundary/);
});
test("D1: today's trend is a sub-line under the greeting, static until ready, hidden without a minute", () => {
const ready = renderToStaticMarkup(
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="虚构的今日趋势句。" dailyTrendSettled />,
);
assert.match(ready, /<h1 id="starter-heading">早上好<\/h1><p class="starter-greeting-sub">虚构的今日趋势句。<\/p>/);
assert.doesNotMatch(ready, /role="status"|aria-busy/);
const pending = renderToStaticMarkup(
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="今天的星语还没写出来。" dailyTrendSettled={false} />,
);
assert.match(pending, /<p class="starter-greeting-sub" role="status">今天的星语还没写出来。<\/p>/);
assert.doesNotMatch(pending, /aria-busy|spinner|inline-spin|正在加载/);
for (const markup of [
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend={null} />),
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} />),
]) {
assert.equal(markup, '<section class="starter-greeting-block" aria-labelledby="starter-heading"><h1 id="starter-heading">早上好</h1></section>');
}
// The trend no longer rides the pills' fine print.
const entries = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
assert.doesNotMatch(entries, /今天的星语还没写出来/);
});
test("D1: page.tsx feeds the sub-line from the same daily state the warm snapshot seeds", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /useState<DailyStarlanguageState>\(\(\) => warmStart\(\)\?\.daily \?\? \{ kind: "pending" \}\)/);
assert.match(page, /<StarterGreeting starterGreeting=\{starterGreeting\} dailyTrend=\{natalMinuteAvailable \? dailyStarlanguageTrend : null\} dailyTrendSettled=\{dailyStarlanguage\.kind === "ready"\} \/>/);
assert.doesNotMatch(page, /dailyStarlanguageTrend=\{/);
});
test("D3: daily entry uses MoonStar, credits use Coins, accessible names unchanged", () => {
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
assert.doesNotMatch(starterHome, /Sparkles/);
assert.match(page, /import \{ Coins \} from "lucide-react";/);
assert.match(page, /<Coins className="credit-icon" aria-hidden="true" \/>/);
assert.doesNotMatch(page, /Sparkles/);
assert.match(page, /aria-label=\{account \? `余额 \$\{account\.credits\} 点,会员与充值` : accountError \|\| "读取余额中"\}/);
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
assert.match(markup, /aria-label="深入看今日"/);
assert.match(markup, /lucide-moon-star/);
});
test("BUG-1041: the homepage parser reads what the entry-summary route actually returns", async () => {
// The route returns NextResponse.json(await getRectificationEntrySummary(...)), i.e. the camelCase
// projection of the snake_case RPC row. Round-trip it through JSON the way the browser receives it.
const accounting = {
rpc: async (fn: string) => {
assert.equal(fn, "get_agentic_rectification_entry_summary");
return {
data: {
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: { case_id: CASE_ID, status: "confirmed", has_usable_time: true },
},
error: null,
};
},
} as unknown as Parameters<typeof getRectificationEntrySummary>[0];
const wire = JSON.parse(JSON.stringify(await getRectificationEntrySummary(accounting, "user-fictional")));
assert.equal("has_terminal_case_with_time" in wire, false, "the route speaks camelCase");
const summary = entrySummaryFromResponse(wire);
assert.equal(summary.hasTerminalCaseWithTime, true);
assert.equal(summary.latestTerminal?.caseId, CASE_ID);
assert.equal(summary.latestTerminal?.hasUsableTime, true);
assert.equal(resolveRectificationEntryAction(summary), "restart");
const resumableWire = JSON.parse(JSON.stringify({
hasResumableCase: true,
hasTerminalCaseWithTime: false,
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
latestTerminal: null,
}));
const resumable = entrySummaryFromResponse(resumableWire);
assert.equal(resumable.hasResumableCase, true);
assert.equal(resumable.latestResumable?.lastActivityAt, "2026-09-26T00:00:00.000Z");
assert.equal(rectificationEntryHint(true, resumable), RESUMABLE_HINT);
});