feat(ui): 星盘 tab 下划线化、参数收成键值表,星历日期条改 ‹ 日期 ›
星盘页(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:
co-authored by
Claude Opus 5
parent
ca0204984b
commit
1e3570b462
@@ -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 error,warning ≤ 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 B(gzip 36,745) | **242,576 B(gzip 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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: "还没有可用来排盘的出生资料。" },
|
||||
|
||||
@@ -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日 这一天的天象来看。");
|
||||
|
||||
Reference in New Issue
Block a user