feat(ui): 星盘 tab 下划线化、参数收成键值表,星历日期条改 ‹ 日期 ›
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped

星盘页(T5.1):
- 五个体系 tab 从五枚描边药丸改成顶栏下方一条下划线式 tab 行,
  flex-wrap: nowrap + 容器自身横滚,375px 下仍是一行;底线画在滚动
  容器的 border 上,不随内容滚走。眉标(BUG-717 的成本与速度承诺)
  原文保留,位置从 tab 行上方移到下方,让 tab 行直接接住 46px 顶栏。
- 分盘 chips 保留但降一级:去描边去卡面、caption 字号、三级色,
  当前项只留浅底。它是一个 tab 内部的筛选器,不是第二层导航。
- 参数从「中宫叠卡(手机两行/桌面六行)+ 盘下重复卡 + media query
  三选一」收成一张八行键值表。同一组事实原本画三遍,改一处要改三处。
  .chart-page-center-card / -compact / -extra / .chart-page-params-below
  / .chart-page-boundary-desktop 五个类连同 767/768 开关块一并删除。
  view.vedic.boundary 那句准确性边界句原文未动,紧跟在表下方,且
  DOM 里从出现两次变成一次。
- 三张行星表(印度宿度 / 西洋相位 / 七政十一曜)全部包上横向滚动
  容器,wrap 补 min-width: 0 —— 少了它 grid item 按内容定宽,表格会
  撑宽页面而不是在自己盒子里滚动。原先只有第一张有 wrap。

星历页(T5.2):
- 日期导航从三个并排等宽按钮改成居中的 ‹ 日期 ›:日期是主语,两枚
  44px 箭头是把手。箭头字形 aria-hidden,可访问名仍是「前一天」
  「后一天」,读屏不丢方向。
- 「今天」只在非今日时以文字按钮出现在日期下方;在今日时整个不渲染,
  而不是渲染成禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。
- 三段统一成同一套卡片:框从内容上移到段落,.ephemeris-panchanga 的
  dl 网格原样保留。收尾句原文不变,但从底部第四张卡降为页尾一行小字。

两条既有断言改动,测试文件内就地写了原值/新值/原因,进度记录成表:
参数断点开关的三条断言换成「参数只有一张键值表且那五个类不再有规则」
(更强,且避开了原写法 indexOf 未命中后静默锚错断点块的陷阱);
「今天」在场的单状态断言拆成今日/非今日两状态并加断不出现 disabled。
测试总数 3350 → 3352,失败清单与基线逐条一致(31 条无 Docker 缺口)。

tsc 0 错;lint 0 error / 118 warning(与基线同数);/ /chart /ephemeris
渲染标记均保持 ○ Static,/reports 仍 ƒ;主 CSS gzip 36,745 → 36,788
(+0.12%)。行星表 375px 横滚、tab 行横滚、读屏播报无真机验收,已在
进度记录里如实写成环境缺口。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:47:50 +00:00
co-authored by Claude Opus 5
parent ca0204984b
commit 1e3570b462
12 changed files with 538 additions and 213 deletions
@@ -0,0 +1,178 @@
# PROGRESS · 星盘 + 星历 R5
任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 R5
执行方式:**直接执行模式**(subagent
分支:`codex/cend-chart-eph-20260916`
基线:`origin/staging` = `50ce02c8`R4 次级页外壳合入后)
BUG 段:不占用(产品改造,未发现真实缺陷)
---
## 开工基线实测
`50ce02c8` 干净检出、`npm ci` 之后实测:
| 项 | 基线值 |
|---|---|
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / **118 warning** |
| `npm test` | tests **3350** · pass 3304 · fail **31** · skipped 15 |
| `next build` 路由标记 | `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ` |
| 主 CSS chunk | 242,608 字节(gzip **36,745** |
31 条失败全部是无 Docker 的既有缺口,清单落在 `/tmp` 基线文件,改后逐条对比。
---
## T5.1 · 星盘页
### 做了什么
| 任务书要求 | 做法 |
|---|---|
| 五个体系 tab 改顶栏下方下划线式 tab 行 | `.chart-page-tabs` 从「五枚描边药丸 + 可换行」改成一条 `flex-wrap: nowrap` + 自身横滚的下划线行,整行 1px 底线,当前项 2px 下划线。JSX 里眉标从 tab 行**之上**移到**之下**,让 tab 行直接接住 46px 顶栏 |
| 分盘 chips 保留、视觉降一级 | `.chart-page-chip` 从与 tab 共用的药丸规则里拆出来:去描边、去卡面、caption 字号、三级文字色,当前项只留一块浅底。触摸目标仍 44px |
| 参数收进统一键值表 | 新增 `chart-param-table.tsx`(由 `chart-param-lines.tsx` 改名而来),八行 `dl` 键值表。删除 `.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类及其 767/768 开关块 |
| 边界句放表下方、文案一字不改 | `{view.vedic.boundary}` 直接跟在表后。改前 DOM 里它出现**两次**(一次给手机一次给桌面,靠 CSS 藏一个),现在只渲染一次。**句子本身是服务端字符串,没有被碰过** |
| 行星表加横向滚动容器 | `.chart-page-planet-table-wrap``min-width: 0`(缺它 grid item 按内容定宽,表格撑宽页面而不是滚动),表加 `min-width: 26rem`。**三张**表全部包上:印度盘宿度、西洋相位、七政十一曜(原来只有第一张有 wrap) |
眉标 `CHART_VIEW_COPY.eyebrow`(「主盘直接算 · 分盘按需 · 不消耗点数」,BUG-717)保留,只换了位置。
按需 layer 拉取(`onNeedLayer("chara"/"western"/"qizheng"/"varga")`)、`CHART_VIEW_COPY.waitingLayer` 等待文案、`waitingChart` / `waitingVarga` 全部未动,没有引入 spinner 或骨架。
### 顺带修掉的一处错位
`.chart-page-eyebrow` 原本同时吃 `.chart-page-body``padding-inline` 和 767 块里给它自己的一份,手机上比 tab 行多缩进一个 `--space-4`。眉标移到 tab 行下方后这个错位会直接看得见,所以把 `.chart-page-eyebrow` 从那条共享选择器列表里摘掉了——**只摘这一个选择器**,同列表里 R6 的 `.report-center-body` / `.report-center-message` 一个没动。
---
## T5.2 · 星历页
| 任务书要求 | 做法 |
|---|---|
| 日期导航改居中 ` 日期 ` | 两枚 `size="icon"`(44px)箭头按钮夹住日期;箭头字形 `aria-hidden`,可访问名用 `EPHEMERIS_COPY.previousDay` / `nextDay`,读屏拿到的仍是「前一天」「后一天」 |
| 「今天」非今日时以文字按钮出现在日期下方 | `isToday ? null : <Button variant="ghost">`,在日期条下方 |
| `isToday` 时不出现禁用按钮 | 整个不渲染。改前是 `disabled={isToday}`,禁用按钮仍要被读屏读、仍要被 Tab 跳过 |
| 三个 section 统一成同一套卡片 | 框从内容上移到段落:`.ephemeris-section` 自己成为卡片,`.ephemeris-transits` / `.ephemeris-events` / `.ephemeris-panchanga-item` 交出各自的描边与卡面 |
| `.ephemeris-panchanga` 的 dl 网格保留 | 桌面 `repeat(5, ...)`、手机 `1fr` 两条规则原样保留(`viewport-breakpoint-contract``ephemeris-page` 两个测试都在守) |
**`.ephemeris-footer` 的处理(与任务书原文的差异,请复核)**:任务书 T5.2 写「底部 `.ephemeris-footer` 删除」。删掉的是那张**卡片**`EPHEMERIS_COPY.footerNote`(「这一页是天象本身,不是对你的判断。」)**原文保留**,改挂成页尾一行 `.ephemeris-outro` 三级色小字。理由:该句是这一页的定性边界句(DESIGN §16「文案」条:这一页是天象本身,不得写运势结论),连句子一起删会丢掉一条口径;而既有测试 `copy stays factual` 也在断言它在场。若产品要的是连句子一起删,请回我,这是一行的事。
四个降级分支 `natalMissing` / `panchangaUnavailable` / `transitsUnavailable` / `eventsUnavailable` 文案与可达性未动;未登录 `.ephemeris-message` 分支未动。
---
## 被改的既有断言
两条,都在下面列清「原值 / 新值 / 原因」,测试文件里也就地写了同样的三行注释。**没有静默弱化**:两条改后都比改前严格。
### 1 · `tests/chart-page-view.test.tsx`
原测试名:`mobile width keeps two centre lines and moves the full parameter card below the chart`
新测试名:`the parameters are one key/value table, not a per-breakpoint duplicate`
| | |
|---|---|
| **原值** | 断 `@media (max-width: 767px)` 块里三个开关:`.chart-page-center-compact { display: block; }``.chart-page-center-extra { display: none; }``.chart-page-params-below { display: block; }` |
| **新值** | 断参数只有一张 `.chart-page-param-row` 网格表;并断上述五个类名(含 `.chart-page-center-card``.chart-page-boundary-desktop`)在整份 CSS 里**都不再作为规则出现** |
| **原因** | 原断言守的正是「同一组参数按断点画三遍」,而 T5.1 要求的就是把它收成一张表,断言无法原样保留。新断言更强:不只要求当前画法对,还禁止任何按断点复制参数的写法回来 |
附带:原写法本身有个陷阱——`globalStyles.indexOf(".chart-page-center-compact")` 未命中时返回 `-1``- 2000` 之后 `indexOf("@media ...", -2001)` 会从文件开头重新搜,**静默锚到无关的断点块上**。新写法走 `cssDeclarations()` / 选择器集合,不做偏移量切片。
同一文件新增一条测试 `the planet table scrolls inside its own box instead of widening the page`(断 `overflow-x: auto` + `min-width: 0` + 表 `min-width`,并断三个 tab 源文件里 `<table>` 数与 `<div wrap>` 数相等)。
### 2 · `tests/ephemeris-page.test.tsx`
| | |
|---|---|
| **原值** | `copy stays factual` 里一句 `assert.match(markup, new RegExp(EPHEMERIS_COPY.today))`,只在 `date === today` 这一种渲染下断言「今天」在场 |
| **新值** | 拆成独立测试 `今天 is absent on today and present off it, never disabled`:今日断「今天」**不**在场,非今日断在场,两个状态都断 `disabled=""` 不出现 |
| **原因** | T5.2 验收标准要求「在今日时不出现『今天』按钮,而不是出现一个禁用按钮」。原断言恰好只覆盖今日那一种情况,照搬会要求今日也渲染该按钮,与验收标准直接冲突。新断言覆盖两个状态并额外禁掉禁用态,严于原来 |
注:断言用 `/disabled=""/` 而不是 `/disabled/`——`Button` 的 Tailwind 类串里本来就含 `disabled:opacity-50`,后者恒真。
测试总数 3350 → **3352**+2,均为新增,无删除)。
---
## 收尾验收对照
| 硬约束 | 结果 | 实测 |
|---|---|---|
| 1 · 只动 `chart-page/` `ephemeris/``.chart-page-*` / `.ephemeris-*` 区段 | ✅ | 见下「改了哪些文件」。`.rectification-*` / `.settings-*` / `.membership-*` / `.report-center-*` / `.personal-report-*` 一处未改;唯一触到的共享规则只摘掉了 `.chart-page-eyebrow` 一个选择器 |
| 2 · 按需 layer 行为不变、等待文案不换 spinner | ✅ | `onNeedLayer` 调用点与 `waitingLayer` / `waitingChart` / `waitingVarga` 逐字未动;`a pending western tab uses inline waiting copy instead of a spinner` 绿 |
| 3 · 四个降级分支文案与可达性不变 | ✅ | `EPHEMERIS_COPY` 未改任何字符串;四个分支渲染路径未动 |
| 4 · `CHART_VIEW_COPY.eyebrow` 保留 | ✅ | 保留,位置从 tab 行上方移到下方 |
| 5 · `tsc` 0 错 / `lint` 0 errorwarning ≤ 118 | ✅ | tsc 0 错;lint 0 error / **118 warning**,与基线同数 |
| 6 · 失败清单与基线逐条一致 | ✅ | `diff base.txt new.txt`**完全相同**fail 仍 31 |
| 7 · 改断言写「原值/新值/原因」三行 | ✅ | 两条,测试文件里就地注释 + 上面成表 |
| 8 · `/chart` `/ephemeris` 保持 `○ Static``/` 保持 `○` | ✅ | 干净重建后 `/` `○` · `/chart` `○` · `/ephemeris` `○` · `/reports` `ƒ`,与基线逐个相同 |
| 9 · 同提交更新 `DESIGN.md`;新文案对照 VOICE | ✅ | §15 §16 重写,见下 |
| 10 · 不升级依赖、不修范围外 warning | ✅ | `package.json` / `package-lock.json` 未动 |
### 改完实测
| 项 | 基线 | 改后 | 差 |
|---|---|---|---|
| `tsc --noEmit` | 0 错 | **0 错** | — |
| `npm run lint` | 0 error / 118 warning | **0 error / 118 warning** | 0 |
| `npm test` | 3350 / fail 31 | **3352 / fail 31** | +2 tests,失败清单逐条一致 |
| `next build` | `/` `○` `/chart` `○` `/ephemeris` `○` `/reports` `ƒ` | **完全相同** | — |
| 主 CSS chunk | 242,608 Bgzip 36,745 | **242,576 Bgzip 36,788** | 原始 32 B(−0.013%),gzip **+43 B+0.12%**,在 ±2% 内 |
gzip 基线是把工作树还原到 `50ce02c8` 后**干净重建**量到的,不是估算。首次改后构建复用了缓存、`.next/static` 里留着基线那份 CSS,两次数字曾一模一样——发现后两边都 `rm -rf .next` 重量,上表是重量后的值。
### 改了哪些文件
```
frontend/src/app/globals.css .chart-page-* / .ephemeris-* 区段)
frontend/src/components/chart-page/chart-page-view.tsx
frontend/src/components/chart-page/chart-param-lines.tsx → chart-param-table.tsx(改名 + 重写)
frontend/src/components/chart-page/chart-qizheng-tab.tsx
frontend/src/components/chart-page/chart-vedic-tab.tsx
frontend/src/components/chart-page/chart-western-tab.tsx
frontend/src/components/ephemeris/ephemeris-page.tsx
frontend/tests/chart-page-view.test.tsx
frontend/tests/ephemeris-page.test.tsx
frontend/DESIGN.md
docs/tasks/PROGRESS-cend-chart-eph-20260916.md
```
CSS 注释配平:改前改后都是 **84 / 84**。含花括号的注释 1 条,是 R4 留下的 `` `*-shell { padding-inline }` ``,本轮未碰;全部删改都走整段精确字符串替换,没有按逗号切选择器。
---
## 触发的让步顺序
**没有触发任何一条。** 让步顺序第 3 条(T5.1 的参数统一表:若合并后信息密度过高则退回保留 `.chart-page-center-card`)是本轮唯一相关的一条,没有用上——八行键值表在 ≥1024 与盘面左右并排、在窄屏是一张独立卡,没有出现密度问题。其余六条属于 R4 / R6 / R7 / R8。
---
## 环境缺口(不得记作「通过」)
本会话**无登录态、无 Chrome、无真实引擎返回**,以下全部只有静态与单测证据,浏览器级行为未验:
1. **行星表在 375px 宽下横向滚动、页面本身不横滚**(任务书环境缺口原文列明的一条)。已有的是 CSS 契约断言(`overflow-x: auto` + `min-width: 0` + 表 `min-width: 26rem`)与「三张表都包了 wrap」的源码断言,**不是真机滚动验收**。
2. **Tab 行在 375px 下横滚**(本轮新写法):五个 Tab 不折行靠 `flex-wrap: nowrap` + 容器横滚,同样只有静态证据。
3. **` 日期 ` 箭头按钮的读屏可访问名**`aria-label` 存在有断言,实际读屏播报未验。
4. **切 tab 时按需 layer 的真实拉取**`onNeedLayer` 调用点未改、单测覆盖渲染分支,但没有真实 `/api/varga_full`、`/api/western`、`/api/qizheng` 往返。
5. **五要素 / 行运 / 未来九十天三张卡在真实数据下的疏密**:fixture 是 golden,但卡片化后的实际观感未上真机。
以上第 1、2、3、5 条建议并进 `docs/testing/` 的人工清单,与 R4 的抽屉手势、R6 的报告目录、R7 的常驻条、R8 的弹窗尺寸一起走一次真机。
---
## 事故:误 pop 了 R6 会话的 stash(已完全恢复,但请知会)
量 gzip 基线时我用了 `git stash push` 把改动暂存、还原到 `50ce02c8` 重建,再 `git stash pop`。**stash 栈是整个仓库共享的一个 ref,不是每个 worktree 一份**——在我 push 与 pop 之间,`codex/cend-report-20260916`(R6)那个会话也 push 了一个 stash,于是我 pop 到的是**他们的** `r6-wip`,落进了我的工作树(`personal-report-center.tsx`、`personal-report-page.tsx`、两个 report 测试,以及他们那份 `globals.css`)。
处置:
1. 从 `git fsck --unreachable` 找回两个 stash commit:我的 `4c112c5b``r5-wip`)、R6 的 `40ff9a82``r6-wip`)。
2. `git stash store` 把 R6 的 `40ff9a82` **原样放回 stash 栈**(内容是 commit 对象,未被改写)。
3. 把误落进我工作树的 5 个文件还原成 `HEAD` 内容,工作树回到干净基线。
4. `git stash apply 4c112c5b` 取回我自己的改动,`git diff HEAD --stat` 与 stash commit 的 stat **逐行相同**10 文件 / +336 203)。
**没有任何 R6 的改动进入本分支的提交**(见上面「改了哪些文件」)。但请主会话转告 R6 会话:他们的 stash 位置变过,现在在栈上(`git stash list` 里的 `On codex/cend-report-20260916: r6-wip`),内容完整;直接 `git stash pop` 之前先确认自己的工作树是干净的。
教训,建议写进 `AGENTS.md` §3**多 worktree 共享一个 stash 栈,禁止用 `git stash` 做临时还原**。要量基线就先提交,或另开一个一次性 worktree 构建,不要动共享的 stash。
+23 -9
View File
@@ -703,15 +703,27 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
### 结构
眉:成功时眉标「主盘直接算 · 分盘按需 · 不消耗点数」+ 标题「星盘」+ 出生资料一行。等待外壳和失败页只有标题和原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**。分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。
名「星盘」与出生资料一行在 `SecondaryShell` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行,眉标「主盘直接算 · 分盘按需 · 不消耗点数」跟在 Tab 行下方作为一行静默说明。等待外壳和失败页只有原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。
五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。五个等重的药丸按钮会读成五个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。chip 比 Tab **低一级**:无描边、无卡面、caption 字号、三级文字色,当前项只有一块浅底。它是一个 Tab 内部的筛选器,不是第二层导航。Tab 与 chip 的触摸目标仍是 44px。
侧栏在「新建对话」之后、「我的报告」之前加「星盘」「星历」两个入口;折叠轨(64px)仍用既有 `report-nav-button` 图标行,当前项用白玻璃面 + 2px 深棕标记,不新造选中态。星历入口指向 `/ephemeris`(由另一单交付,合入前 404 是预期)。
### 中宫排盘参数
### 排盘参数
北印盘几何中心叠一张参数卡。桌面(≥768)六行:钟表时间与校正状态、出生地经纬、岁差、交点模式、上升与月亮星宿、当前大运分运与引擎名。手机(≤767)中宫只留岁差与交点两行;完整六行改到盘下方一张卡。切点跟壳层,不另造 390
参数是**一张键值表**`.chart-page-params-table`),八行:出生时间(含校正状态、出生地(含经纬、岁差、交点、上升、月宿、当前大运、引擎。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、分盘 chip、返回均 44px
**不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、分盘 chip 均 44px。
### 行星表
三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`,表本身 `min-width: 26rem`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。
### 三套坐标系
@@ -737,14 +749,16 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 16. 星历页
独立 route `/ephemeris`侧栏入口由星盘页那一单加;本页不改 `app-sidebar.tsx`,合入前用直接访问 URL 验收。不扣点、不调模型。`html, body` 仍然 `overflow: hidden`,本页用 `.ephemeris-shell` 自管滚动,和报告中心同一做法
独立 route `/ephemeris`不扣点、不调模型。页面挂在 `SecondaryShell` 上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西
- **结构:** 顶栏返回对话;标题「星历」;日期条;今日五要素;当日行运;未来九十天;底部出口卡。顺序固定。
- **日期条:** 「前一天 / 今天 / 后一天」三个 ≥44px 真按钮。切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
- **五要素:** 桌面五张卡一行(星期、月相日、月宿含第几足、瑜伽、迦那)。767px 以下收成一张卡里的五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句
- **结构:** 日期条;今日五要素;当日行运;未来九十天;收尾一句。顺序固定。
- **日期条:** 居中的 ` 日期 `——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行
- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
- **出口:** 底部一张卡,一句「这一页是天象本身,不是对你的判断。」主按钮「带这天去提问」把当前日期预填进对话草稿并回到 `/`
- **出口:** 收尾句「这一页是天象本身,不是对你的判断。」原文不变,但不再是底部第四张卡——它是页尾一行三级色小字。一句话的卡片跟三张装数据的卡片等重是错的。主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。
+138 -92
View File
@@ -1995,7 +1995,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.chart-page-eyebrow {
width: min(1100px, 100%);
margin: var(--space-4) auto 0;
margin: 0 auto var(--space-6);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
@@ -2073,8 +2073,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.report-center-body,
.chart-page-message,
.ephemeris-message,
.report-center-message,
.chart-page-eyebrow { padding-inline: var(--space-4); }
.report-center-message { padding-inline: var(--space-4); }
}
/* Starter home: greeting, composer, entry pills */
@@ -4500,30 +4499,62 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* The five systems are one underline tab row directly under the app header,
not five bordered pills floating on the page. Five pills of equal weight read
as five buttons you might press; a tab row reads as one control with a
current position. The row scrolls sideways rather than wrapping, so the
header hand-off stays a single line at 375px — the border sits on the
scroll container itself and therefore does not scroll away. */
.chart-page-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-6);
flex-wrap: nowrap;
gap: var(--space-4);
margin-bottom: var(--space-4);
padding-top: var(--space-2);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.chart-page-tab,
.chart-page-chip {
.chart-page-tabs::-webkit-scrollbar { display: none; }
.chart-page-tab {
flex: 0 0 auto;
min-height: 44px;
min-width: 44px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
padding: 0 var(--space-1);
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-body-sm);
font-weight: 500;
white-space: nowrap;
}
.chart-page-tab.is-current,
.chart-page-tab:hover { color: var(--color-ink); }
.chart-page-tab.is-current {
border-bottom-color: var(--color-focus);
color: var(--color-ink);
}
/* Vargas are a filter inside one tab, not a second navigation level, so the
chips sit a rung below the tab row: no frame, no card face, caption type. */
.chart-page-chip {
min-height: 44px;
min-width: 36px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-ink-tertiary);
cursor: pointer;
font-size: var(--type-caption);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.chart-page-chip:hover { color: var(--color-ink-secondary); }
.chart-page-chip.is-current {
background: var(--color-selected);
border-color: var(--color-border-strong);
box-shadow: inset 2px 0 0 var(--color-focus);
color: var(--color-ink);
}
.chart-page-vedic,
@@ -4531,14 +4562,13 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.chart-page-dasha,
.chart-page-western,
.chart-page-qizheng,
.chart-page-western-side,
.chart-page-center-compact {
.chart-page-western-side {
min-width: 0;
}
.chart-page-varga-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.chart-page-vedic-stage {
@@ -4550,40 +4580,52 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
}
.chart-page-vedic-stage .personal-report-chart-svg { margin-top: 0; }
.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; }
.chart-page-center-card {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(42%, 168px);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-canvas) 92%, transparent);
color: var(--color-ink-secondary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: 0;
line-height: 1.35;
text-align: center;
}
.chart-page-center-card p { margin: 0; }
.chart-page-center-extra { display: none; }
.chart-page-params-below {
display: none;
margin-top: var(--space-4);
/* One key/value table replaces the `.chart-page-center-card` overlay (two lines
on mobile, six on desktop, sat on the chart's geometric centre) and the
`.chart-page-params-below` card that repeated the same six lines for mobile.
The same facts were laid out three ways and switched by media query; naming
each field once removes the per-breakpoint duplication entirely. */
.chart-page-params {
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.chart-page-params-below p { margin: 0 0 var(--space-2); }
.chart-page-planet-table-wrap { overflow-x: auto; }
.chart-page-params-table { margin: 0; }
.chart-page-param-row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: var(--space-3);
align-items: baseline;
padding: var(--space-2) 0;
border-top: 1px solid var(--color-border);
}
.chart-page-param-row:first-child { border-top: 0; padding-top: 0; }
.chart-page-param-row dt {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-weight: 500;
}
.chart-page-param-row dd {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
line-height: 1.5;
overflow-wrap: anywhere;
}
/* `min-width: 0` matters as much as the overflow: without it a grid item is
sized by its content and the table pushes the page sideways instead of
scrolling inside this box. */
.chart-page-planet-table-wrap {
min-width: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.chart-page-planet-table {
width: 100%;
min-width: 26rem;
border-collapse: collapse;
background: var(--color-canvas);
font-size: var(--type-body-sm);
@@ -4710,17 +4752,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
}
.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); }
@media (max-width: 767px) {
.chart-page-center-compact { display: block; }
.chart-page-center-extra { display: none; }
.chart-page-params-below { display: block; }
.chart-page-boundary-desktop { display: none; }
}
@media (min-width: 768px) {
.chart-page-center-compact { display: none; }
.chart-page-center-extra { display: block; }
.chart-page-params-below { display: none; }
.chart-page-basics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -4732,6 +4764,9 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
gap: var(--space-6);
align-items: start;
}
/* Chart and parameters sit side by side; the planet table is a full-width
row under both, because its columns need the whole measure. */
.chart-page-planet-table-wrap { grid-column: 1 / -1; }
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@@ -4749,34 +4784,55 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
Ephemeris page — facts only, own scroll (html/body overflow hidden)
============================================================ */.ephemeris-hero,
.ephemeris-section,
.ephemeris-footer{
.ephemeris-outro{
width: min(900px, 100%);
margin-inline: auto;
}
.ephemeris-hero {
display: grid;
gap: var(--space-4);
justify-items: center;
gap: var(--space-2);
padding: var(--space-6) 0 var(--space-8);
}
.ephemeris-date-label {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-title-sm);
min-width: 12ch;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-md);
font-weight: 500;
text-align: center;
}
.ephemeris-date-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.ephemeris-date-button {
min-width: 44px;
min-height: 44px;
font-size: var(--type-title-md);
line-height: 1;
}
/* Only rendered off today, so it never needs a disabled face. */
.ephemeris-today {
min-height: 44px;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
/* One card per section. The three bodies used to draw their own frames while
the section around them drew none, so 五要素 / 行运 / 未来九十天 each looked
like a different kind of object. The frame belongs to the section; what is
inside it is rows. */
.ephemeris-section {
display: grid;
gap: var(--space-3);
margin-bottom: var(--space-10);
margin-bottom: var(--space-6);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.ephemeris-section h2 {
margin: 0;
@@ -4806,10 +4862,10 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
display: grid;
gap: var(--space-2);
margin: 0;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
padding: var(--space-3);
border: 0;
border-radius: var(--radius-sm);
background: var(--color-canvas-muted);
}
.ephemeris-panchanga-item dt {
margin: 0;
@@ -4834,9 +4890,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
}
.ephemeris-transits {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
border-radius: var(--radius-sm);
}
.ephemeris-transits-head,
.ephemeris-transits-row {
@@ -4844,11 +4898,11 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
grid-template-columns: 4.5rem minmax(0, 1fr) 5rem 6.5rem 3rem;
gap: var(--space-3);
align-items: center;
padding: var(--space-3) var(--space-4);
padding: var(--space-3) 0;
min-height: 44px;
}
.ephemeris-transits-head {
background: var(--color-canvas-muted);
border-bottom: 1px solid var(--color-border);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-weight: 500;
@@ -4865,9 +4919,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
padding: 0;
list-style: none;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
border-radius: var(--radius-sm);
}
.ephemeris-events li {
display: grid;
@@ -4875,7 +4927,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
gap: var(--space-4);
align-items: center;
min-height: 44px;
padding: var(--space-3) var(--space-4);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border);
font-size: var(--type-body-sm);
}
@@ -4884,17 +4936,10 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.ephemeris-footer {
display: grid;
gap: var(--space-4);
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.ephemeris-footer p {
.ephemeris-outro {
margin: 0;
color: var(--color-ink-secondary);
padding-bottom: var(--space-4);
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.ephemeris-ask { min-height: 44px; width: fit-content; }
@@ -4915,21 +4960,21 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.ephemeris-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
@media (max-width: 767px) {
.ephemeris-date-bar { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.ephemeris-date-button { width: 100%; }
/* The arrows stay arrow-sized and the date keeps the middle: stretching two
glyph buttons to a third of the width each is what made the old bar read as
three equal actions. */
.ephemeris-date-bar { gap: var(--space-1); }
.ephemeris-panchanga {
grid-template-columns: 1fr;
gap: 0;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
border-radius: var(--radius-sm);
}
.ephemeris-panchanga-item {
grid-template-columns: 5.5rem minmax(0, 1fr);
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
padding: var(--space-3) 0;
border: 0;
border-top: 1px solid var(--color-border);
border-radius: 0;
@@ -4941,7 +4986,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.ephemeris-transits-row {
grid-template-columns: 1fr 1fr;
gap: var(--space-2) var(--space-4);
padding: var(--space-4);
padding: var(--space-3) 0;
}
.ephemeris-transits-row span::before {
content: attr(data-label);
@@ -4951,6 +4996,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
font-weight: 500;
}
.ephemeris-events li { grid-template-columns: 1fr; gap: var(--space-1); }
.ephemeris-section { padding: var(--space-4); }
.ephemeris-ask { width: 100%; }
}
@@ -74,10 +74,6 @@ export function ChartPageView({
</section>
) : (
<div className="chart-page-body">
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<div className="chart-page-tabs" role="tablist" aria-label="星盘体系">
{CHART_VIEW_TABS.map((item) => (
<button
@@ -92,6 +88,11 @@ export function ChartPageView({
</button>
))}
</div>
{/* BUG-717 copy: a cost and speed promise that also prints on the
failure page. It used to sit in the hero as an eyebrow; the hero is
gone, the promise is not. It reads under the tab row now, because
the tab row is what the header hands off to. */}
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
<section className="chart-page-panel" role="tabpanel">
{tab === "vedic" ? (
<ChartVedicTab
@@ -1,31 +0,0 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatCoord } from "./chart-format";
export function ChartParamLines({
view,
compact,
}: {
view: ChartViewOk;
compact?: boolean;
}) {
const { profile } = view;
const place = `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`;
if (compact) {
return (
<>
<p> {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
</>
);
}
return (
<>
<p>{profile.time} · {profile.birthTimeStatusLabel}</p>
<p>{place}</p>
<p> {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
<p> {profile.ascendantSignLabel} · 宿 {profile.moonNakshatra} {profile.moonPada}</p>
<p>{profile.currentDashaLabel} · {profile.engineName}</p>
</>
);
}
@@ -0,0 +1,40 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatCoord } from "./chart-format";
/**
* The chart parameters as one key/value table.
*
* They used to be rendered three times over: a `.chart-page-center-card`
* floating on the geometric centre of the North Indian chart carried two lines
* on mobile and six on desktop, and a `.chart-page-params-below` card repeated
* the same six lines for mobile only. One set of facts, three renders, switched
* by media queries and every line was a bare sentence with its label fused to
* its value ( Raman). A table names each field once and scans down one
* column, so nothing needs duplicating per breakpoint.
*
* Every value here is passed through from the server projection; nothing on
* this page is computed in the browser.
*/
export function ChartParamTable({ view }: { view: ChartViewOk }) {
const { profile } = view;
const rows: ReadonlyArray<readonly [string, string]> = [
["出生时间", `${profile.time} · ${profile.birthTimeStatusLabel}`],
["出生地", `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`],
["岁差", profile.ayanamsaDisplay],
["交点", profile.nodeModeLabel],
["上升", profile.ascendantSignLabel],
["月宿", `${profile.moonNakshatra}${profile.moonPada}`],
["当前大运", profile.currentDashaLabel],
["引擎", profile.engineName],
];
return (
<dl className="chart-page-params-table">
{rows.map(([label, value]) => (
<div className="chart-page-param-row" key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
@@ -27,29 +27,31 @@ export function ChartQizhengTab({ view }: { view: ChartViewOk }) {
<p className="chart-page-note">{qizheng.ketuMode} · {qizheng.coordinateSystem}</p>
<Rules />
<QizhengPalaceGrid qizheng={qizheng} />
<table className="chart-page-planet-table">
<caption>宿</caption>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">宿</th>
<th scope="col">宿</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>宿</caption>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">宿</th>
<th scope="col">宿</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -4,7 +4,7 @@ import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
import { formatDegree } from "./chart-format";
import { ChartParamLines } from "./chart-param-lines";
import { ChartParamTable } from "./chart-param-table";
export function ChartVedicTab({
view,
@@ -45,17 +45,12 @@ export function ChartVedicTab({
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<VedicChartSvg chart={varga.chart} ariaLabel={`${varga.id} 北印度盘 ${varga.meaning}`} />
<div className="chart-page-center-card">
<div className="chart-page-center-compact">
<ChartParamLines view={view} compact />
</div>
<div className="chart-page-center-extra">
<ChartParamLines view={view} />
</div>
</div>
</div>
<div className="chart-page-params-below">
<ChartParamLines view={view} />
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
<div className="chart-page-planet-table-wrap">
@@ -83,7 +78,6 @@ export function ChartVedicTab({
</div>
</div>
)}
{varga ? <p className="chart-page-boundary chart-page-boundary-desktop">{view.vedic.boundary}</p> : null}
</div>
);
}
@@ -26,24 +26,26 @@ export function ChartWesternTab({ view }: { view: ChartViewOk }) {
</section>
<section>
<h3></h3>
<table className="chart-page-planet-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p> {western.houseSystem === "P" ? "Placidus" : western.houseSystem}</p>
</div>
@@ -126,18 +126,42 @@ export function EphemerisView(props: {
>
<div className="ephemeris-body">
<section className="ephemeris-hero">
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
{/* ` `: the date is the subject, the two arrows are its handles.
Three equal side-by-side buttons made / / read as
three peers and pushed the date itself out of the group entirely.
The arrow glyphs are decorative the direction words stay as the
accessible names, so nothing is lost to a screen reader. */}
<div className="ephemeris-date-bar" role="group" aria-label="切换日期">
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onPrevious}>
{EPHEMERIS_COPY.previousDay}
<Button
type="button"
variant="outline"
size="icon"
className="ephemeris-date-button"
aria-label={EPHEMERIS_COPY.previousDay}
onClick={props.onPrevious}
>
<span aria-hidden="true"></span>
</Button>
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onToday} disabled={isToday}>
{EPHEMERIS_COPY.today}
</Button>
<Button type="button" variant="outline" className="ephemeris-date-button" onClick={props.onNext}>
{EPHEMERIS_COPY.nextDay}
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
<Button
type="button"
variant="outline"
size="icon"
className="ephemeris-date-button"
aria-label={EPHEMERIS_COPY.nextDay}
onClick={props.onNext}
>
<span aria-hidden="true"></span>
</Button>
</div>
{/* On today there is nowhere to go, so the control is absent rather than
present-but-disabled: a greyed button still asks to be read and
still has to be tabbed past. */}
{isToday ? null : (
<Button type="button" variant="ghost" className="ephemeris-today" onClick={props.onToday}>
{EPHEMERIS_COPY.today}
</Button>
)}
</section>
<section className="ephemeris-section" aria-labelledby="ephemeris-panchanga-heading">
@@ -203,9 +227,10 @@ export function EphemerisView(props: {
)}
</section>
<section className="ephemeris-footer">
<p>{EPHEMERIS_COPY.footerNote}</p>
</section>
{/* The closing note used to be a fourth boxed card competing with the
three that carry data. The sentence is unchanged; it is a quiet line
under the page now, and the action lives in the header. */}
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
</div>
</SecondaryShell>
);
+53 -14
View File
@@ -24,6 +24,18 @@ const golden = JSON.parse(
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const vedicTab = readFileSync(new URL("../src/components/chart-page/chart-vedic-tab.tsx", import.meta.url), "utf8");
const westernTab = readFileSync(new URL("../src/components/chart-page/chart-western-tab.tsx", import.meta.url), "utf8");
const qizhengTab = readFileSync(new URL("../src/components/chart-page/chart-qizheng-tab.tsx", import.meta.url), "utf8");
/** Every selector that has at least one rule, comments stripped first. */
function declaredSelectors(source: string): Set<string> {
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
const found = new Set<string>();
for (const [, selectorList] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
for (const target of selectorList.split(",")) found.add(target.trim().replace(/\s+/g, " "));
}
return found;
}
const vedicSvg = readFileSync(new URL("../src/components/personal-report/vedic-chart-svg.tsx", import.meta.url), "utf8");
const profile = {
@@ -126,25 +138,52 @@ test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的
assert.match(sidebar, /tooltip="星历"/);
});
test("mobile width keeps two centre lines and moves the full parameter card below the chart", () => {
// 原值:三条断言都以 `@media (max-width: 767px) { .chart-page-hero h1` 起锚,
// 再加一条 `.chart-page-back` 的 44px 触摸目标。
// 新值:直接断那个断点块里的三个开关;`.chart-page-back` 一条删除。
// 原因:hero 与「返回对话」按钮随页面并入 app 外壳而删除(T4.2)。被断言的三个
// 开关本身一条没动,只是不再有 hero 那一行可以用来起锚。返回路径现在是
// 常驻侧栏,它的触摸目标由 sidebar-contract 管
const mobile = globalStyles.slice(
globalStyles.indexOf("@media (max-width: 767px)", globalStyles.indexOf(".chart-page-center-compact")
- 2000),
);
assert.match(mobile, /\.chart-page-center-compact \{ display: block; \}/);
assert.match(mobile, /\.chart-page-center-extra \{ display: none; \}/);
assert.match(mobile, /\.chart-page-params-below \{ display: block; \}/);
test("the parameters are one key/value table, not a per-breakpoint duplicate", () => {
// 原值:断言 `@media (max-width: 767px)` 块里的三个开关
// `.chart-page-center-compact { display: block }` /
// `.chart-page-center-extra { display: none }` /
// `.chart-page-params-below { display: block }`。
// 新值:断言参数只有一张 `.chart-page-params-table` 键值表,且那三个类名在
// 整份 CSS 里都不再作为规则出现
// 原因:T5.1 要求参数从中宫卡与盘下卡收进一张统一键值表(决策记录 D8 一系)。
// 原断言守的是「同一组参数按断点切换三种画法」,那正是本轮要去掉的东西,
// 断言无法原样保留。新断言比原来更强:它不只要求当前画法正确,还禁止
// 任何一种按断点复制参数的写法回来。
// 注:原写法本身也是个陷阱——`indexOf` 未命中时返回 -1`-2000` 之后
// 会从文件开头重新搜 `@media`,静默锚到无关的断点块上。
assert.match(cssDeclarations(".chart-page-param-row", globalStyles), /grid-template-columns/);
for (const dead of [
".chart-page-center-card",
".chart-page-center-compact",
".chart-page-center-extra",
".chart-page-params-below",
".chart-page-boundary-desktop",
]) {
assert.equal(
declaredSelectors(globalStyles).has(dead),
false,
`${dead} still has a CSS rule`,
);
}
assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/);
assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/);
});
test("the planet table scrolls inside its own box instead of widening the page", () => {
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /overflow-x:\s*auto/);
// Without `min-width: 0` a grid item is sized by its content, so the table
// pushes the page sideways and the overflow never engages.
assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /min-width:\s*0/);
assert.match(cssDeclarations(".chart-page-planet-table", globalStyles), /min-width:\s*\d/);
// Every planet table is wrapped, not just the one on the 星盘 tab.
for (const source of [vedicTab, westernTab, qizhengTab]) {
const tables = [...source.matchAll(/<table className="chart-page-planet-table">/g)].length;
const wraps = [...source.matchAll(/<div className="chart-page-planet-table-wrap">/g)].length;
assert.equal(wraps, tables);
}
});
test("an incomplete profile page has no loading animation", () => {
const markup = renderToStaticMarkup(React.createElement(ChartPageView, {
view: { status: "birth_profile_incomplete", billed: false, message: "还没有可用来排盘的出生资料。" },
+16 -1
View File
@@ -114,6 +114,22 @@ test("the Lahiri note sits under panchanga and not under transits", () => {
assert.doesNotMatch(transitsBlock, new RegExp(EPHEMERIS_COPY.panchangaLahiriNote));
});
test("今天 is absent on today and present off it, never disabled", () => {
// 原值:`assert.match(markup, new RegExp(EPHEMERIS_COPY.today))` —— 在
// 「copy stays factual」里对唯一一次渲染(date === today)断言「今天」在场。
// 新值:拆成两个状态各断一次,并加断 `disabled` 不出现。
// 原因:T5.2 验收标准要求「在今日时不出现『今天』按钮,而不是出现一个禁用按钮」。
// 原断言恰好只覆盖 date === today 这一种情况,照搬会要求今日也渲染该按钮,
// 与验收标准直接冲突。新断言覆盖两个状态,比原来严格。
const onToday = render(payloadFor("2026-09-15"));
assert.doesNotMatch(onToday, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(onToday, /disabled=""/);
const offToday = render(payloadFor("2026-09-14"));
assert.match(offToday, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(offToday, /disabled=""/);
});
test("mobile CSS turns five panchanga cards into one stacked card", () => {
const markup = render(payloadFor("2026-09-15"));
assert.match(markup, /class="ephemeris-panchanga"/);
@@ -129,7 +145,6 @@ test("copy stays factual: no fortune-telling, no spinner, ask prefills the date"
assert.match(markup, new RegExp(EPHEMERIS_COPY.ask));
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay));
assert.match(markup, new RegExp(EPHEMERIS_COPY.today));
assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/);
assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i);
assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。");