From 64aa0ec20c89b9e493905e9ea127137972858401 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 29 Sep 2026 19:03:28 +0800 Subject: [PATCH] feat(chart): polish chart presentation and symbol explanations Hide engine branding, gate supported divisional columns, align house cells, and add accessible symbol help. Reduce desktop chart width and stabilize the waiting slot. Validated with 78 browser checks and 87 focused frontend tests. Full suite retains 29 identical baseline loader failures; no new failures. Co-Authored-By: Claude Code --- BLOCKED.md | 8 + CHANGELOG.md | 8 + docs/BUG_HISTORY.md | 29 ++++ docs/research/pre_work_error_ledger.md | 13 ++ .../PROGRESS-chart-surface-polish-20260929.md | 148 +++++++++++++++++ docs/tasks/README.md | 1 + .../TASK-chart-surface-polish-20260929.md | 154 +++++++++++++++++ .../TASK-chart-surface-polish-fix-20260929.md | 24 +++ docs/testing/chart-surface-polish-20260929.md | 41 +++++ frontend/DESIGN.md | 12 +- frontend/docs/VOICE.md | 7 + frontend/src/app/globals.css | 29 +++- .../chart-page/chart-page-skeleton.tsx | 12 +- .../chart-page/chart-param-table.tsx | 1 - .../chart-page/chart-symbol-help.tsx | 61 +++++++ .../components/chart-page/chart-vedic-tab.tsx | 51 +++--- .../chart-page/chart-western-tab.tsx | 21 ++- .../chart-page/western-wheel-svg.tsx | 133 ++++++++++----- .../personal-report/vedic-chart-svg.tsx | 66 ++++++-- frontend/src/lib/chart-view-contract.ts | 5 +- frontend/src/lib/chart-view-mapper.ts | 10 +- frontend/tests/chart-bhava.test.tsx | 15 ++ frontend/tests/chart-moon-transit.test.tsx | 15 ++ frontend/tests/chart-page-hook.test.ts | 55 ++++++ .../chart-page-mobile-width-20260929.test.ts | 27 +++ frontend/tests/chart-page-view.test.tsx | 18 ++ frontend/tests/chart-planet-glyphs.test.tsx | 7 +- frontend/tests/chart-symbol-help.test.tsx | 157 ++++++++++++++++++ frontend/tests/chart-varga-table.test.tsx | 128 +++++++++++++- .../test_chart_page_vedic_tables_contract.py | 75 +++++++++ 30 files changed, 1230 insertions(+), 101 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-surface-polish-20260929.md create mode 100644 docs/tasks/TASK-chart-surface-polish-20260929.md create mode 100644 docs/tasks/TASK-chart-surface-polish-fix-20260929.md create mode 100644 docs/testing/chart-surface-polish-20260929.md create mode 100644 frontend/src/components/chart-page/chart-symbol-help.tsx create mode 100644 frontend/tests/chart-symbol-help.test.tsx diff --git a/BLOCKED.md b/BLOCKED.md index 66994f12..d06c2f4f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,13 @@ # BLOCKED +## TASK-chart-surface-polish:受控登录、实体手机与基线全量失败(2026-09-29) + +- 本轮 Chrome、Edge、Docker 均可用,不套用历史“无 Chrome / 无 Docker”。真实 Chrome + golden 的本地组件验收及骨架高度修复后复验已完成(70+8 项通过);没有受控线上登录账号、实体手机和读屏实测;不能代替完整账户/人物/请求链路。清单见 `docs/testing/chart-surface-polish-20260929.md`。 +- Linux 保真基线全量前端 4369 项,4340 通过 / 29 失败 / 0 cancelled / 0 skipped,exit 1;两个既有生命周期测试文件均报 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向及关闭 tsx 缓存仍复现。final-r2 为 4386 项、4357 通过 / 29 失败 / 0 cancelled / 0 skipped,exit 1;逐名逐原因核对无新增失败、无诊断变化。根因未定,直接 import 同包正常,不能称包损坏;不改依赖或测试消红,不声称全量通过。证据见本轮 PROGRESS 和 `artifacts/chart-surface-polish-validation-20260929/baseline-test-summary.json`。 +- 本轮 pre_work_check exit 1:远端 verified、适配器成功,但碎片扫描 90 秒、focused tests 45 秒超时。未伪造历史镜像、未清其他会话目录。 +- 真实 Chrome 前后对照发现既有印度盘多星同宫的个别字形边缘裁切,基线/实现坐标相同、可见中心仍能触发正确说明。本轮没有引入或修复该布局欠账;不将“无新增裁切”说成“全部字形无裁切”。证据见本轮 PROGRESS 的 `vedicVisibleAudit`。 +- 前次任务书文档推送认证失败尚未解除;本轮只实施与本地验收,未提交、未推送、未部署,不借用任何凭据。 + ## ~~TASK-birth-sky-cover T5:报告封面没有可靠的出生资料绑定(2026-09-28)~~ → 已解除:产品 2026-09-28 选 A → 2026-09-29 产品决定去掉报告封面(TASK-mobile-chart-and-confirmed-edit-20260929 M5;生成时写入的 `subject.birthFingerprint` 保留) - ~~任务书要求「只有报告生成时的出生资料与现在一致时才显示封面」。逐项查过,报告侧**没有任何出生资料指纹**:~~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 14513953..8a6dd329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-29 — 星盘展示去引擎、能力列收口与图标说明(本地实施完成,未发布) + +- 星盘参数不再展示引擎品牌;岁差、交点与准确性边界保留。 +- 分盘庙旺保留入旺 / 自宫 / 落陷,同位只在 D9 显示;上升庙旺写「不适用」。宫位表头与数值统一左对齐。 +- 盘内符号增加鼠标、键盘与轻点说明,不增加运势解读,不改变西洋盘点选相位。 +- 桌面印度绘图区上限 440px、西洋 480px;手机结果盘保持原自适应,报告与校正盘不缩。等待盘沿用相同宽度,并预留图例高度,避免结果出现时盘框跳动。 +- Skill 版本不 bump;不改算法、数据库、许可证或依赖版本。验收状态见本轮 PROGRESS。 + ## 2026-09-29 — 年运返照时刻改为真正的本地时间;PL9 导出文件减半(待验收) - 专业报告里「Varshapravesha(太阳返照时刻)」此前打印的是世界时(北京盘差 8 小时,跨日时日期也错),现在按出生地时区显示;有夏令时的地区按返照那一刻的实际偏移(BUG-1095)。Sahams 昼夜判定也改用真实返照时刻。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2c80170c..1d744257 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2604,6 +2604,7 @@ - 验证:个人报告聚焦测试覆盖 answer-first 顺序、十二宫坐标/裁切/密集数据、独立滚动边界、打印表格与分页、原生附录 disclosure、PDF 操作能力保护;浏览器在 1363×936 CSS viewport 实测报告容器 `clientHeight=936`、`scrollHeight=3466`,滚动后 `scrollTop=980` 并可到达底部,附录展开后高度增至 5219;参考图与实现图完成同画布视觉比对。聚焦 ESLint、生产构建和 `git diff --check` 作为最终门禁重新执行。 - 防复发:聊天根滚动锁与报告阅读滚动必须分层;报告回归测试不得删除 `100dvh/overflow-y:auto` 合同。任何命盘 occupants 改动必须验证至少两个不同宫位 transform、clipPath 与 12 项密集输入。打印回归必须覆盖长摘要、长主题、最小宽度表格和无滚动容器的 A4 输出;PDF 入口的隐藏/恢复必须同步更新明确的能力与 ready-state 合同。 - 2026-09-24 产品修订:`TASK-report-reader-actions-20260924` D2 明确撤下打印按钮与专用调用,不是此 Bug 复发;A4、壳层解锁、表格分页以及 beforeprint/afterprint 展开/恢复保留,浏览器 Ctrl+P 仍可用。本地候选只更新入口合同,不把受阻完整测试、真机打印或部署记为通过。 +- 2026-09-29 关联观察(investigating,未修复):星盘展示单真实 Chrome 在 390px 的既有 D1 golden 发现个别边缘 glyph 的 bbox 超出 SVG;基线/实现坐标相同,可见中心均能触发正确说明。本轮未改布局坐标/clipPath,不能认定为本轮新增,也未证实与本记录原根因相同。旧 transform/密集裁切合同不等于所有真实字体边界都不裁切;后续布局单应补真实字体 bbox 与可见区域检查。证据见 `PROGRESS-chart-surface-polish-20260929.md` 的 `vedicVisibleAudit`;本轮不扩改报告/校正布局算法。 - 相关记录:BUG-126 - 复发自:无 - 修复版本:本地候选(未 push / deploy) @@ -14730,3 +14731,31 @@ - 相关记录:BUG-1095、ERR-111 - 复发自:无 - 修复版本:分支 `codex/upstream-sync3-20260929`,未合入 + +## BUG-1098 | 星盘行星表宫位标题左对齐,内容误用居中样式 + +- 状态:investigating(2026-09-29 本地修复及独立浏览器验证通过;最终全量无新增失败,但保留 29 项既有加载失败,未部署、线上待验) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`chart-vedic-tab.tsx` 分盘、本命/月亮、Bhava、行运表格与对应 CSS。 +- 现象 / 触发:打开上述盘型,宫位标题与每行宫位内容不在同一左边线。 +- 根因:标题继承默认左对齐,宫位 td 误用 `chart-page-planet-mid`。旧测试验证数据、列数与 D1 字节冻结,没有检查宫位 computed alignment;分盘之外同类写法也存在。 +- 修复:分盘、Bhava、行运仅移除宫位格 mid;本命/月亮保留原八列 markup,通过八列表头限定 CSS 第四列左对齐。宿主、pada、速度、换宫等刻意居中列不变,不全局改 mid。 +- 验证:执行方前端定向与 D1 字节冻结通过;新增源码/样式合同。独立真实 Chrome 六盘型×六视口确认宫位 computed text-align left、标题和值 padding 12px、宿主/pada仍居中,窄屏无整页横滚;独立全量结果见 `PROGRESS-chart-surface-polish-20260929.md`。未部署,不提前宣布线上解决。 +- 防复发:各盘宫位标题/值同左对齐和 padding;保留 BUG-1083 外层可收缩与表内横滚合同。 +- 相关记录:BUG-1076、BUG-1083;本地诊断任务书记录延续,非重新编号。 +- 复发自:无旧对齐修复证据;属于既有表格未覆盖的展示问题。 +- 修复版本:`codex/chart-surface-polish-20260929` 工作区,未提交、未推送。 + +## BUG-1099 | 星盘分盘表把全部 Dn 同座比较统一展示为原生同位能力 + +- 状态:investigating(2026-09-29 D9 能力边界本地验证通过;最终全量无新增失败,但保留 29 项既有加载失败,未部署、线上待验) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`chart-view-mapper.ts::buildVargas`、`chartViewVargaSchema`、`chart-vedic-tab.tsx::VargaTable`。 +- 现象 / 触发:非 D9 分盘也显示同位,没有能力声明,false 横杠无法区分不支持与未命中。 +- 根因:09-28 产品约定将任意 Dn 与 D1 同座泛化为 vargottama;原生 `_calc_vargottama` 只处理 D9。算术比较本身没错,旧测试按旧产品定义验证,并非计算回归漏检。 +- 修复:服务端可选能力声明,两列独立严格 true 才展示;D9 有有效来源时支持同位,其他 Dn 隐藏;三档庙旺保留,上升标不适用。支持性由有效来源而非结果真值判断。旧模块快照缺声明保守隐藏,不改原始引擎缓存或身份隔离。 +- 验证:能力组合、全 false/null、坏类型、缺失/无效源、缓存直读与分层合并均增加 golden 回归;Python 合同直接将真实 TS mapper 与 `_calc_vargottama` 行星和 `build_d9_summary` 上升分别对照。执行方及独立定向通过,最终 87 项受影响前端测试、5 项 Python 合同全部通过;全量新增 17 项、删除 0,29 项基线加载失败逐名逐原因一致。完整证据见 PROGRESS。 +- 防复发:禁止用 `some(vargottama)` 判断支持,禁止将未知补 false;缺能力整列不渲染,分盘行不能退回 D1。 +- 相关记录:BUG-1076、`TASK-chart-types-and-report-buttons-20260928.md` T3;本任务书明确有限修订旧约定。 +- 复发自:无;产品术语能力范围的收口,不是 D1 冒充分盘复发。 +- 修复版本:`codex/chart-surface-polish-20260929` 工作区,未提交、未推送。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index f8106661..e5f13be7 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,19 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-29 · 星盘展示获准推送后的预检 + +- 产品在获知本轮全量 29 项既有失败后明确授权推送 staging。重新 fetch,远端仍为已验基线 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`;19 个业务/测试变动文件与 final-r2 快照哈希一致,隐私检查 63/63 通过。 +- 推送前预检这次不再超时:remote verified、碎片扫描与适配器成功;focused 22 通过 / 2 失败,整体 exit 1。复现历史同类机器目录假设:缺 `.workbuddy/skills/jyotish-vedic-astrology`,candidate_count=6 小于 workspace_residue_count=1937。未造镜像、未清其他会话目录、未修改测试;原日志 `artifacts/chart-surface-polish-validation-20260929/push-pre-work-check.log` 保留。 +- 此处只记录推送准备,写认证、远端 SHA 与门禁/部署另行核验;不把远端可读当作已写入。 + +## 2026-09-29 · 星盘展示实施预检与保真基线 + +- 独立工作树 `codex/chart-surface-polish-20260929`,fetch 后固定 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`。Windows Python 3.11.7 加 UTF-8 环境变量运行预检;remote verified、适配器成功,碎片扫描 90 秒与 focused tests 45 秒分别超时,整体 exit 1。原日志在本轮 `artifacts/chart-surface-polish-validation-20260929/pre-work-check.log`。不套用此前单条镜像断言失败,不造目录、不清他人残留。 +- 本机 Docker 29.8.0、真实 Chrome 可用。Linux 独立 Git archive 基线保留 5485 tracked 文件及 8 个 symlink,mode 校验零差异;不以 Windows checkout 的 symlink/mode 替代 Linux 结果。Node 22.23.2 / Python 3.13.15,不更改依赖锁。 +- 基线 npm 全量 4369 项中 29 项失败,集中两个生命周期文件的 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向重跑与关闭 tsx 缓存仍复现;不是 Docker 不可用。保留失败原日志,最终逐名逐原因比较,不修改业务或断言迎合工具。tsc/lint/build 与 Python quick 基线成功,具体数字见本轮 PROGRESS。 +- 浏览器 harness 初稿误落主检出 artifact,随后复制到本轮实现树,后续产物只写独立工作树;未清理其他会话文件。前次纯文档写认证失败未解除,本轮无 push、无部署。 + ## 2026-09-26 · 校正时延单预检(BUG-1047) - `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 在 Linux / Python 3.13 上整体 exit 0:远端 verified,碎片扫描、外部引擎适配器诊断与 5 个 focused 目标全部通过(本机没有复现 09-24/09-25 记录的 `.workbuddy` 镜像断言失败)。 diff --git a/docs/tasks/PROGRESS-chart-surface-polish-20260929.md b/docs/tasks/PROGRESS-chart-surface-polish-20260929.md new file mode 100644 index 00000000..8ecdd543 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-surface-polish-20260929.md @@ -0,0 +1,148 @@ +# PROGRESS · 星盘展示收口(2026-09-29) + +## 授权与基线 + +- 产品要求“请你执行任务书”,对应 `TASK-chart-surface-polish-20260929.md`;实施计划已批准。 +- fetch 后基线仍为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,独立工作树 `.worktrees/chart-surface-polish-20260929`,分支 `codex/chart-surface-polish-20260929`。 +- 未 stash、未提交、未推送、未部署;前次文档写认证失败尚未解除,不尝试借用凭据。浏览器 harness 初稿曾误写主检出 artifact,已复制至本树,后续产物仅写本树,未清理其他会话文件。 +- 工作树清单与会话检查未见同日报告两单正在本机修改共享 SVG;保持本单共享 SVG 先于后续报告单串行接续的约定。 + +## 执行边界与补充决策 + +- 去掉用户可见引擎行及专用展示字段,不删内部计算元信息或许可证。 +- 庙旺保留三档同源派生,同位只 D9;能力声明与命中结果分离,旧缓存缺声明保守隐藏,不新造缓存系统。 +- 宫位对齐审计发现本命/月亮、Bhava、行运也使用相同居中类。按 T3 统一修复宫位类格;本命八列结构和数据字节冻结不动,以八列表头限定 CSS 第四列左对齐。宿主、pada 等仍居中。 +- 盘宽只在既有 ≥1024px 断点收紧;印度限内部绘图区,参数卡和表格不缩,报告/校正不受影响。 +- 图标说明由 `/chart` 页面交互包装层承接,共享绘图默认静态。固定说明区避免挡住相邻符号;静态导出白名单不扩。西洋固定文字放在透明行星命中圆之上,避免说明归属被截走。 + +## 进度 + +| 项 | 状态 | +| --- | --- | +| T1 去引擎 / T2 能力列 / T3 对齐 | 已实现;定向通过 | +| T4 图标说明 | 已实现;定向与真实浏览器通过 | +| T5 桌面盘宽与等待槽位 | 已实现;六视口真实浏览器通过 | +| 基线全量与最终验收 | final-r2 完成,无新增失败;全量仍有 29 项既有失败,独立只读审查闭环 | +| 文档与真人清单 | 已回填;受控登录、实体手机和读屏留待真人验收 | + +## 本轮环境与预检 + +| 项目 | 实测 | +| --- | --- | +| Windows Node / npm | 22.23.2 / 10.8.1 | +| Windows Python | 3.11.7(UTF-8 环境变量) | +| Docker Server | 29.8.0 | +| 实际浏览器 | Chrome 154.0.8037.57,独立临时配置,无个人登录态 | +| Linux Node / npm / Python | 22.23.2 / 10.9.8 / 3.13.15 | +| 实现树 npm ci --ignore-scripts | exit 0;未改 lockfile | +| pre_work_check | exit 1:remote verified、适配器成功;碎片扫描 90s 超时,focused tests 45s 超时 | + +预检原日志:`artifacts/chart-surface-polish-validation-20260929/pre-work-check.log`。本轮不是旧镜像路径断言的失败计数,不伪造历史目录、不清理别人的残留、不把整体 fail 写成通过。 + +Linux 基线使用本轮独立 Node/Python/DinD 容器和卷,Git archive 保留 5485 tracked 文件及 8 个 symlink,精确 index/mode 校验零差异。不挂宿主 Docker socket,不更改依赖锁。archive SHA256:`23f0b42bad804e59362959c64fa282ed4437faa301773f98b731e6ddfbcee76a`。最终另建快照,不覆盖基线。 + +## 既有断言变更账本 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 西洋所有视口 max-width 520px | 窄屏仍 520px,≥1024px 上限 480px;viewBox 520×520 保留 | 只收紧桌面绘图区,不改变手机和几何 | +| 完整 markup 中“逆”只出现一次 | 只统计可见 text,仍一次;另增说明与默认静态无交互断言 | title/aria 的事实说明可含逆行,不能误当成盘内重复标签 | +| 临时浏览器追加检查要求骨架 SVG 与结果 SVG 总高相等 | 要求绘图容器、stage 等高及说明起点相同;400 方盘主体同宽同高,内部空 SVG 不要求绘制图例带 | 以真实无跳动布局为合同,保留静态空 SVG;旧失败原日志保留,外框高度断言未放宽 | + +数据能力测试只新增断言,原 D9 六列、D1 八列字节冻结保留。没有删除用例来消红。 + +## 执行方与主会话定向验证 + +| 检查 | 结果 | +| --- | --- | +| 数据六文件前端定向 | 55 pass / 0 fail / 0 cancelled,exit 0 | +| Python 表格/原生同位合同 | 5 passed,exit 0 | +| 图标、静态报告、西洋布局与新交互 | 31 pass / 0 fail / 0 cancelled | +| 星盘页、西洋布局与新交互 | 36 pass / 0 fail / 0 cancelled | +| 主会话独立星盘页、尺寸、交互定向(初轮) | 32 pass / 0 fail / 0 cancelled / 0 skipped,exit 0 | +| 主会话 r2 定向(含高度合同及 Bhava/Transit 成功参数表) | 42 pass / 0 fail / 0 cancelled / 0 skipped,exit 0 | +| 主会话 Python 原生合同 / 隐私 | 5 + 63 passed,exit 0 | +| 主会话 tsc / 完整 lint | exit 0 / exit 0(0 error / 126 warning) | +| 主会话差异空白检查 | exit 0 | + +数据代理首轮新增缓存测试 54/55,因新测试缺少身份 pin 前置而失败;补齐测试前置后整组 55/55,未修改生产缓存逻辑。图标测试是实际 React handler/生命周期测试,不伪称浏览器布局仿真。主会话原日志:`artifacts/chart-surface-polish-validation-20260929/main-targeted.log`。 + +## Linux 同环境基线 / 最终对账 + +证据目录:`artifacts/chart-surface-polish-validation-20260929/`。 + +| 检查 | 基线 | final-r2 | +| --- | --- | --- | +| tsc | exit 0 | exit 0 | +| lint | 0 error / 126 warning,exit 0 | 0 error / 126 warning,exit 0 | +| npm test | 4369 项:4340 pass / 29 fail / 0 cancelled / 0 skipped,exit 1 | 4386 项:4357 pass / 29 fail / 0 cancelled / 0 skipped,exit 1 | +| 测试名多重集合 | 顶层 4339;含嵌套 4369 | 顶层 4356;含嵌套 4386;新增 17、删除 0 | +| build / 首页 | exit 0 / ○ Static | exit 0 / ○ Static | +| 完整首屏直链 JS/CSS gzip | 720027 B(32 资源) | 720933 B,+0.12583% | +| 相对父 layout 增量 JS gzip | 233663 B(8 块) | 234101 B,+0.18745% | +| 首页专属 JS gzip | 133914 B(4 块) | 134352 B,+0.32708% | +| Python 定向 / 隐私 | 3 + 63 passed,exit 0 | 5 + 63 passed,exit 0 | +| Python quick | 1000 passed / 2 skipped / 3 subtests passed,exit 0 | 1000 passed / 2 skipped / 3 subtests passed,exit 0 | +| 受影响前端定向 | — | 87/87 pass,fail/cancelled/skipped/todo 0,exit 0 | + +quick 使用 `--profile quick --skip-frontend-runtime`,前端另外完整执行 tsc/lint/npm test/build,不省 Python 核心。基线 29 项失败均在 `home-warm-return-lifecycle.test.tsx`(14)和 `new-chat-from-people-lifecycle.test.tsx`(15),报 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向及禁用 tsx 缓存重复同错。不属于 Docker 不可用,不改业务/测试/依赖迎合工具;final-r2 已逐名核对 name/error/code/location(仅归一快照目录),新增失败 0、诊断变化 0。主会话独立读取 summary、名称多重集合与失败诊断证据,未将 exit 1 写成成功。最终证据:`final-r2-summary.json`、`final-r2-versus-baseline.json`、`final-r2-failure-diagnostics-versus-baseline.json`、`final-r2-gzip-versus-baseline.json`。 + +## 真实浏览器验收 + +高度修复后最终稳定轮:`artifacts/chart-surface-polish-browser-20260929/run-2026-09-29T10-31-46-677Z/results.json`;触控/高度追加轮 `run-2026-09-29T10-31-31-386Z/results.json`。脚本 `run-browser.cjs`、日志 `acceptance-post-height-fix.log` 与 `touch-height-post-fix.log`。两轮使用同一源码哈希快照;旧 `run-2026-09-29T10-18-19-676Z` 初轮和其后高度失败证据保留。Chrome 真实组件 + 完整 CSS + 项目字体 + 真实引擎 golden,同一 harness 前后对照,源码前后哈希一致。 + +| 验证 | 实测 | +| --- | --- | +| 检查汇总 | 主套 70 PASS / 0 FAIL + 追加 8 PASS / 0 FAIL,两次 exit 0;pageerror 0(未监听全部 console.error) | +| 宫位对齐 | 6 盘型 × 6 视口,computed text-align left,标题/值 padding 12px;宿主/pada仍center | +| 西洋拥挤盘 | 64 golden × 6 视口 = 384 盘;17190 字符命中归属/nearest语义归属错误 0,字框碰撞 0 | +| 375px 西洋 | 最小实际字号 12.533px;命中直径 44.854px | +| 交互 | 实际 hover/focus/tap/Esc/leave/blur/切盘清理;西洋选择/取消/相位过滤、星座/宫号/角点说明通过 | +| 印度可见符号 | 10 个可见 glyph 中心均能触发正确说明;逆行线同对象 | +| 共享绘图 | 报告实际组件与校正共享宿主六视口尺寸同基线;导出静态、无脚本或交互、无 height auto | +| 页面宽度与等待 | 无整页横滚,表内可滚;六视口骨架与结果绘图区同宽 | + +| 视口 px | 基线印度 px | 实现印度 px | 基线西洋 px | 实现西洋 px | +| --- | ---: | ---: | ---: | ---: | +| 375 | 317 | 317 | 343 | 343 | +| 390 | 332 | 332 | 358 | 358 | +| 480 | 422 | 422 | 448 | 448 | +| 1023 | 949 | 949 | 520 | 520 | +| 1024 | 497.594 | 440 | 520 | 480 | +| 1280 | 565.797 | 440 | 520 | 480 | + +浏览器第一轮运行时实现方仍在调整西洋文字层,哈希不稳定,不算最终证据;harness 也修正了零高度 SVG 逆行线定位(改几何中心真实 mouse.move)和扩展 aria-label 前缀定位,没有放宽业务断言。最终稳定轮已重跑全套。 + +### 既有视觉欠账与未验边界 + +- 390px D1 个别多星同宫字形 bbox 越 SVG 边界,基线/实现坐标与可见中心相同,属于既有裁切,不是本轮新增;可见中心能正确说明,不把“无新增裁切”写成“全部字形无裁切”。证据含 `vedicVisibleAudit` 和前后截图。 +- 没有受控线上登录态、实体手机或真实读屏验收。校正只覆盖共享绘图宿主,不是完整 Case 流程;本地 golden harness 不能代替账户/人物/服务端请求链路。 +- 真人清单:`docs/testing/chart-surface-polish-20260929.md`;缺口保留在 `BLOCKED.md`。 + +## 独立审查与追加修复 + +修复单:`TASK-chart-surface-polish-fix-20260929.md`,与原任务同一授权/分支,不扩大发布范围。 + +- 未参与实现的审查方完成能力声明、缓存、静态 SVG、安全边界与西洋事件审查,最小 20 项测试通过、fail/cancelled 0、exit 0,未发现确定的新数据或交互回归。 +- 审查发现 T5 高度验收缺口:骨架空 SVG 为 400×400,结果有图例为 400×452。虽是既存比例差,本任务书要求揭幕后不跳尺寸,因此不缩窄解释为“只限宽度”。追加 Chrome 实测六视口外框增高 41.203 / 43.156 / 54.859 / 123.359 / 57.188 / 57.188px,六项失败、exit 1;日志 `touch-height.log` 和 `run-2026-09-29T10-23-27-737Z/results.json` 保留。 +- 最小修复只给 `/chart` 印度骨架容器 `.chart-page-skeleton.chart-page-vedic-plot` 添加 `aspect-ratio:400/452`,留出图例带;不改空 SVG/viewBox,不改报告/校正。新增合同;新稳定浏览器追加轮 8 PASS / 0 FAIL、exit 0,六视口绘图槽/外框高度及说明起点差均为 0,方盘主体尺寸差 <0.1px。证据 `run-2026-09-29T10-31-31-386Z/results.json`,pageerror 0、源码哈希稳定。内部两个 SVG 自身不要求等高,因为骨架不绘图例。 +- 触控追加可信原生事件序列为 pointerup(touch) → pointerout/leave(touch) → 兼容鼠标事件 → click(touch),印度/西洋在完整事件后 0/50/500/1500ms 说明均保留;没有复现触控立即消失。不扩推到所有移动浏览器。 +- 补强 Bhava/Transit 的真实成功 golden 参数表断言:参数表、岁差、交点存在且无引擎,两个文件 9/9 pass、fail/cancelled/skip 0、exit 0。不是仅验证失败占位不含引擎。 +- 29 项基线错误只能定性为既有生命周期测试加载失败,根因未定:同环境直接 import tailwind-merge 并执行正常,不能写成缺依赖或包损坏。 +- final-r1 为中间快照,不能替代上述改动;4385 项中 4356 pass / 29 fail / 0 cancelled / 0 skipped,新增 16 个测试名、删除 0,29 项失败 name/error/code/location 归一快照路径后逐项相同。tsc/lint/build/quick 成功,首页 Static。r1 最初 Python 与 Node 依赖复制并行导致新增两项 skip,依赖就绪后真实补跑 5 个定向 + 63 隐私全部通过,原日志不覆盖。 +- final-r2 新保真快照 SHA256 为 `7af1408accee383ade7906c638d032fc992cca8e06986f43526616a58c804a34`,5491 tracked、index/mode 零差异。主会话逐项核对 19 个变动业务/测试文件 source_sha256 与当前树一致,mismatch 0;文档最终回填另做隐私检查(主会话已独立 63/63 pass,exit 0)。 + +## 当前结论 + +本地实施完成:五项展示需求及独立审查发现的骨架高度缺口均已落实,冻结后真实浏览器 78/78、受影响前端定向 87/87、tsc/lint/build、Python 定向与 quick 通过;首页 Static,三种 gzip 变化均小于 2%。独立审查无未处理意见,主会话另外完成关键复验。 + +全量 npm 仍为 exit 1:29 项既有生命周期加载失败与基线逐名逐原因完全一致,新增失败 0;不能称全部测试全绿。预检超时、实体手机/读屏和受控线上链路未验、既有印度边缘裁切均明确保留。BUG-1098/1099 记录为本地验证通过但线上待验,不宣称生产已解决。 + +本地验收完成时,交付仅限工作树 `G:/Ferti/Jyotisha/.worktrees/chart-surface-polish-20260929`,尚未提交、推送或部署。算法、依赖锁、迁移、发布配置未改。本地证据不能替代远端 SHA/部署核验。 + +## 2026-09-29 · staging 推送授权 + +- 产品在获知上述 29 项既有全量失败及真人验收缺口后,明确要求“请你帮我推送到staging”。本轮授权包括提交已验收改动并快进推送 staging,不包括提升 main 或手动生产发布。 +- 推送前重新 fetch;`origin/staging` 与本轮基线仍同为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,无需合并或重放代码。重新执行预检、源码快照一致性与隐私检查;只提交明确列出的业务/测试/文档,不纳入本地产物、依赖、截图或凭据。 +- 推送前源码核对 19 文件 mismatch 0,隐私 63/63 通过。预检 exit 1:remote verified、碎片与适配器成功;focused 22 pass / 2 fail,分别是历史镜像路径不存在与 candidate_count=6 小于 workspace_residue_count=1937,不再是开工时超时。原日志 `push-pre-work-check.log` 保留,未修改测试或清理其他会话目录。 +- 本节记录授权与推送准备,不预言写认证、远端门禁或部署成功。提交后须单独核对远端 SHA,自动门禁/部署以新提交的实际结果为准。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 3a528321..652e63e6 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -229,6 +229,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| [TASK-chart-surface-polish-20260929](TASK-chart-surface-polish-20260929.md)([验收修复](TASK-chart-surface-polish-fix-20260929.md)) | [PROGRESS](PROGRESS-chart-surface-polish-20260929.md) | 去引擎展示、庙旺三档保留、同位仅 D9、宫位左对齐、图标解释与桌面盘宽收紧(BUG-1098 / 1099) | 本地实施完成(定向/浏览器通过;全量保留 29 项基线失败,未推送) | `codex/chart-surface-polish-20260929`;不发布、不提升 main | | `TASK-docs-reconcile-20260926.md` | `PROGRESS-docs-reconcile-20260926.md` | **状态板 / BUG / BLOCKED 过期对账**(纯文档):约 35 行校正任务状态过期、BUG-085/086/743/981/984 记录过期、BLOCKED latency 条目过期;逐条附证据改,不把欠真机写成通过 | 待验收 | `codex/docs-reconcile-20260926`(本地提交,未推送) | | `TASK-people-ephemeris-ui-20260926.md` | `PROGRESS-people-ephemeris-ui-20260926.md` | **星盘档案页补样式 + 星历可读性改版**(原型 https://claude.ai/artifact/FmPy9xZ7dQw9u7yrW4WzTw ,产品 09-26 确认):/people 样式 0 条(BUG-1039)→ 手机列表→详情、桌面两栏;星历五要素先中文后梵文(附唯一对照表)、吉凶彩色标签、固定规则当日总评(不调模型)、标题栏大按钮移到底部提示卡。必须真实浏览器截图验收。与 BUG-1038 单并行、文件不交叉 | 已验收(Claude 09-26 直接执行模式:子代理实现,Claude 独立复验 tsc/lint 0、全量 3916 条失败名单与基线逐条一致、四路由 ○、gzip +0.00%、36 张浏览器截图核对与原型一致) | `0ab061b9`(已部署,run 2910,health 一致) | | `TASK-round-0925-followup-20260925.md` | `PROGRESS-round-0925-followup-20260925.md` | **0925 收尾单**:西洋盘标签偏移 ≤30° + 第三圈;删除后只在当前人物里回退;人物接口故障降级为本人;P3 删旧面板、点击按最近行星、删除年运死调用 | 已验收(Claude 09-26:偏移 ≤30° 达成;删后回退按人、人物接口降级、账户单次请求、面板与死代码删除均通过;残留 0.73% 盘标签重叠为 P3) | `d08ffdc0` + 合同修复 `e84d6eb8`(d08ffdc0 的门禁 run 2901 红于一条引用已删面板文案的 Python 合同,Claude 经产品授权直接修复);已部署 run 2906,health 一致 | diff --git a/docs/tasks/TASK-chart-surface-polish-20260929.md b/docs/tasks/TASK-chart-surface-polish-20260929.md new file mode 100644 index 00000000..5b426f31 --- /dev/null +++ b/docs/tasks/TASK-chart-surface-polish-20260929.md @@ -0,0 +1,154 @@ +# TASK · 星盘去引擎展示、能力列按支持显示、宫位对齐、图标解释与盘面收紧(2026-09-29) + +> 初稿为任务书模式。产品于 2026-09-29 明确要求“请你执行任务书”,已进入独立工作树实施;执行后独立验收,本轮不推送、不发布。 +> +> 执行补充:尺寸只在既有 ≥1024px 桌面两栏断点收紧,印度 440px 指绘图区而非含 padding 的外框。T3 同时修复本命、月亮、Bhava、行运的同类宫位对齐;本命/月亮保留原八列 markup,按八列表头精确限定 CSS 第四列,不全局改 `mid`。 + +## 1. 基线与串行依赖 + +- 调查基线:`origin/staging = 78b9c5b65cda44650e8c7ca019931ce33a85a95d`。执行开工重新 fetch,记录实际 SHA。 +- 实现分支:`codex/chart-surface-polish-20260929`;工作树:`.worktrees/chart-surface-polish-20260929`。 +- 进度:`docs/tasks/PROGRESS-chart-surface-polish-20260929.md`;真人清单:`docs/testing/chart-surface-polish-20260929.md`。 +- 必须接在 `codex/mobile-chart-confirmed-edit-20260929`(BUG-1083)合入之后;本基线已包含其修复。不得重新引入手机宽表撑开外层网格的问题。 +- 同日报告两单既有顺序不变:`codex/report-reader-polish-20260929` → `codex/report-english-edition-20260929`。本单优先限定 `/chart`;若要同时编辑共享 `vedic-chart-svg.tsx`、报告图盘导出或同一 CSS/文档段落,必须串行交接,不能各自在旧基线上覆盖。默认本单共享 SVG 改动先合入,英文版再接续;若对方已开工,先协调,不重置别人分支。 + +## 2. 事故实证与根因 + +本轮输入为产品截图和展示要求,不记录截图中的真实星盘数据。本轮没有登录态浏览器复现或全量业务验收。代码定位按符号为准,以下行号对应上述基线: + +| 现象 | 证据与根因 | +| --- | --- | +| 所有印度盘显示引擎来源 | `frontend/src/lib/chart-view-mapper.ts:822` 投影 `engineName`,`chart-view-contract.ts:261` 要求该字段;`components/chart-page/chart-param-table.tsx:28` 把它作为参数行无条件渲染。`chart-vedic-tab.tsx:312` 的共享 `VedicLayout` 让该行出现在全部 23 种印度盘型。西洋盘不走此表,不能声称其已有同一行。 | +| 分盘宫位标题左、内容居中(BUG-1098) | `chart-vedic-tab.tsx:148,163` 的 `VargaRow` / `VargaAscendantRow` 给宫位套了 `chart-page-planet-mid`。`globals.css:5210–5215` 本来统一左对齐,却被 `:5249` 的该类覆盖成居中。是单元格类使用不当,不是引擎数据错误。 | +| 盘内符号无悬停解释 | `components/personal-report/vedic-chart-svg.tsx:168–220` 的 `OccupantGlyph` 渲染行星、逆行短线、As,没有逐符号 ``;整盘读屏描述不能替代鼠标提示。西洋盘 `components/chart-page/western-wheel-svg.tsx` 的行星交互组已有键盘/选择语义,须在其上补解释而非换一套事件。 | +| 印度盘可能铺得过大 | `globals.css:5136–5142` 的 `.chart-page-vedic-stage` 未设最大宽度;桌面 `:5508–5519` 第一列为 `minmax(0,1.1fr)`,盘随列宽铺开。西洋盘 `.chart-page-western-wheel`(`:5417–5421`)已有 520px 上限。尺寸取舍不是计算缺陷。 | + +### 历史约束核对 + +已确认的产品契约冲突: + +- `frontend/DESIGN.md` §15「排盘参数表」原来明确规定八行,最后一行为「引擎」。因此移除是新的产品决定,不是丢字段。 +- 09-28 `TASK-chart-types-and-report-buttons-20260928.md` T3 规定分盘庙旺只取入旺 / 自宫 / 落陷,且同位是分盘与本命同星座;不能以空白多直接认定计算不支持,也不能把它包装成全面尊贵状态或外部精度认证。 +- 09-18 符号化改造让宫内只画行星符号;读屏信息、图例和鼠标悬停提示不是一回事,不能以已有 `aria-label` 代替可见解释。 + +### 数据能力核查(代码证据,不是外部占星准确率验证) + +| 范围 | 实际来源 | 本单处理 | +| --- | --- | --- | +| D1 状态 | `/api/chart` 的原生 `_get_dignity_level`;旧八列表 | 保留,不改其计算 | +| D2–D60 行星庙旺 | `chart-view-mapper.ts:216–253` 服务端 `vargaRows` 调用 `chart-view-varga.ts:19–23`;规则表由 `tests/test_chart_page_vedic_tables_contract.py` 与 Python 原生常量及九体×十二星座逐格对照 | 保留三档能力:入旺 / 自宫 / 落陷。不是 `/api/varga_full` 原生返回字段,但为有合同保护的同源派生,不能说引擎不支持。没有三档命中为 `null`;不代表未知或不支持 | +| D9 行星同位 | mapper 比较 D1/D9 星座;`scripts/jyotish_engine.py:300–313` `_calc_vargottama` 原生判定也是 D1↔D9 | 保留;必须加星盘投影与原生结果的直接对照测试 | +| D9 上升同位 | mapper 比较 D1/D9 上升;原生 `consultation_native_layers.build_d9_summary` 与 `satabdika_dasha.satabdika_applicability` 有同座判定路径 | 保留已有事实比较,补同源合同;不要声称 `_calc_vargottama` 本身处理上升 | +| 其他 Dn 同位 | mapper 对任意分盘都用 `sign === natalSign`,并统一命名 `vargottama`;原生 `_calc_vargottama` 并无各 Dn 的这套泛化 | 按产品“不支持不显示”的原则,本单收口:只在 D9 显示「同位」,其他 Dn 隐藏。不是断言通用同座比较算错,而是限制产品术语的能力范围 | +| 上升庙旺 | 无此字段,组件固定「—」 | 不对上升假造尊严计算;行星能力列仍在,上升此格标「不适用」并对照 VOICE | +| 月亮 / Bhava / 行运 / 西洋 | 独立表,无上述两列 | 不新增 | + +`/api/varga_full` 本身没有这两字段;既有契约的 `dignity` 为三枚举或 null、`vargottama` 为必填 boolean,**没有独立的 unsupported 状态**。缺分盘/本命星座时 mapper 会跳过该行。支持性应由服务端的盘型能力和有效数据来源表示,不能由真值多少推断,也不能让未知被 false 吞掉。本轮只读查到上述测试存在,尚未运行其回归,不以旧记录代替本轮结果。 + +## 3. 决策记录 + +| 项 | 产品授权 / 本单执行口径 | +| --- | --- | +| D1 去引擎 | 产品要求所有盘型不再显示「引擎」「Swiss Ephemeris(本站)」这类实现品牌。删除展示节点,不改为「自研引擎」「本站引擎」,不只用 CSS 藏起来。印度各盘型与西洋盘都查。 | +| D2 能力列 | 有实际计算依据才显示「庙旺 / 同位」;没有该能力时隐藏整列,不留一列横杠。支持但未命中不能当成不支持。此次不扩建算法、不虚构结果。 | +| D3 宫位对齐 | 宫位标题与每行内容统一左对齐,同列左右 padding 一致。不是把表头改成居中来迁就错误的单元格样式。 | +| D4 图标说明 | 鼠标停在图标上显示中文名称和已有事实;键盘能获得等价信息。手机没有 hover,提供可用的轻点说明且不与现有点选动作冲突。不加运势解释,不调用模型。 | +| D5 盘面收紧 | 适度缩小星盘页的盘面,优先收紧桌面最大宽度;手机不机械按比例缩小文字与点击区域。不改数据坐标,不重做绘图。 | + +**对旧约定的有限修订**:D1 推翻 DESIGN §15 参数表必须含引擎的旧约定;D2 将 09-28 T3 与 BUG-1076 中「所有分盘同位 = 与本命同星座」的泛化展示收口为 **D9 才显示同位**(基于此次“不支持则不显示”的授权,架构建议;不新造另名列),庙旺的同源三档派生保留;D3 只修宫位列,宿主 / pada 的居中不顺带改;D5 允许修改星盘页尺寸上限,保留 BUG-1083 的无溢出和西洋盘点击 / 避让规则。版权、许可证、第三方声明与内部诊断元数据不删除;隐藏展示不是让技术栈保密的安全措施。 + +## 4. 硬红线 + +1. 不改本命 / 分盘 / Bhava / 行运 / 西洋盘的计算结果、岁差、交点、宫制或时间精度提示;不拿 D1 数据补分盘(BUG-1076)。 +2. 不在浏览器补算庙旺或同位。数据不支持就不显示,未知不伪装为 false / 正常。 +3. 服务端计算元数据和审计来源保留;不得全仓字符串替换或删除许可证署名。此次只收口面向用户的盘面展示,不清洗既有存储报告或原始引擎 JSON。 +4. 星盘页的盘型条、按需取数、缓存身份、等待 / 失败 / 重试规则不变;不新增请求、模型调用或 loading 动画。 +5. 盘面文字与触控可用性优先于尺寸;西洋盘现有行星点选过滤相位、再次点选取消必须保留。多星同宫和拥挤盘不许通过缩字掩盖重叠。 +6. 共用 SVG 同时供校正右栏、报告与静态导出使用。不得直接把静态绘图组件整体改为依赖浏览器的客户端组件;交互只在需要的页面接入,导出仍是安全的静态 SVG。图表缩小样式只作用于 `/chart`。 +7. 不动数据库、依赖版本、workflow、DNS、`main`,不碰首页装配。遵守 AGENTS 增长合同,不另加净增零行约束。 +8. 改代码之前在 `frontend/tests/` 与 `tests/` 检索相关路径、符号、类名和文案;既有断言改动逐条写「原值 / 新值 / 原因」,不删测试消红。fixture 使用现有真实引擎 golden 或虚构输入生成的真实引擎响应。 + +## 5. 任务分解与验收 + +### T1 · 去除盘面引擎展示 + +- 删除参数表引擎行;`engineName` 在 chart-page view schema / mapper 中只为该行服务,删除这项无用展示投影并同步 fixture / 缓存兼容,不影响原始后端计算元数据。检查所有印度盘型和西洋盘的可见说明是否仍出现实现来源。 +- 不误删岁差 / 交点 / 宫制等影响结果的参数,不误删准确性边界。 +- **验收**:遍历全部盘型,渲染结果无引擎标签和供应商名称;西洋说明同样通过。后台/内部数据来源仍可追踪。若报告中共享盘块原有相同标签,同步去掉盘块标签,但不扩成报告正文清洗任务。 + +### T2 · 能力列按支持显示 + +- 开工先复核本文数据能力结论,分别记录庙旺与同位的有效来源、覆盖范围和缺值含义。使用最小的服务端列能力契约(如每盘 `supportsDignity` / `supportsVargottama`,或等价的可用性字段),无需新建全站能力注册系统。D9 保留两列,其他 Dn 保留庙旺、隐藏同位;上升庙旺标不适用。 +- 兼容旧浏览器模块缓存:契约新增字段不得导致旧缓存无声显示错误,按现有 schema/缓存换键方式处理;缺能力声明时保守隐藏对应列,不能假定支持。由 service mapper 输出列能力,组件只消费,不用分散的 JSX 魔法判断。 +- 支持性独立于结果值:`false` 是有效同位结果;未命中入旺 / 自宫 / 落陷也可能是正常有效结果。不能以 `rows.some(row => row.vargottama)` 或某列有非空字符串作为能力判断。 +- 不支持的列整列不渲染(含表头和上升行);单颗星不适用时可留「—」。两列分别控制,不能同开同关。 +- **验收**:真实 golden 的支持盘保留结果;缺失能力、只支持其中一列、两列都不支持、支持但全不命中分别覆盖。缺字段/无效字段的防御测试须从真实 golden 复制后做定向删除或破坏,不手造引擎响应形状。 + +### T3 · 宫位同列左对齐 + +- 精确收窄表格样式到宫位单元格和表头,审计本命、分盘、月亮、Bhava 和行运的同类宫位列。 +- **验收**:桌面与手机截图中宫位标题和内容同一左边线;自动化断言 computed style 和边距,而不只看 JSX;宿主/pada 等刻意居中列不变,横向滚动仍在表格内部。 + +### T4 · 图标解释 + +- 覆盖盘内行星、上升标记、逆行标记及存在的星座/角点缩写,明确每种图标含义。文案只报名称、已知位置与顺逆等事实,禁止编个人运势。 +- 复用已有 tooltip / 提示样式和现有数据,图例与读屏信息保留。可在 SVG 中补安全的 `<title>` 作为基础描述,但原生 title 不等于已完成 focus/触屏可见提示;页面级增强不要污染静态绘图层。没有度数等精细数据时只显示有依据的名称,不从粗略显示串造精度。 +- `frontend/src/lib/report-chart-export.tsx:10–25` 的 SVG serializer 当前不允许 `title`。本单导出的静态 SVG **不要求悬停交互**,允许该描述节点按现有规则剥离;不得为图标提示顺带放开脚本、事件属性或任意 SVG 标签白名单。已有图例与事实文本保留,测试明确静态导出不带交互。 +- hover 与 focus 可见;离开/失焦/Esc 可关闭;不挡相邻图标。触屏不得依赖原生 title 长按;西洋盘轻点仍完成原有选择,解释随选中显示或采用不冲突的等价方式。 +- **验收**:鼠标、键盘、触屏各一条交互测试;至少覆盖多星同宫、逆行、上升、切盘后无旧提示;报告/校正共用渲染和静态 SVG 安全合同仍过。 + +### T5 · 缩小盘面 + +- 推荐初值:`/chart` 印度盘**绘图区宽度上限 440px**,西洋盘 **480px**(不把 stage padding 算成盘宽),同时 `width:100%` 自适应并居中。印度盘相对 BUG-1083 在桌面记录的 480px 约收紧一成,西洋盘从 520px 小幅收紧;实际基线宽度小于该值时不能反而放大。此数值是设计目标,不是本轮已实测结论,按下方可读性验收可减小缩幅。 +- 参数区和下方表格不随盘面一起变窄。骨架盘与结果盘使用相同宽度容器,揭幕后不跳尺寸。 +- 手机保留自适应宽度和原可读性。缩小只改容器布局,不用 `transform: scale()` 留下大空白或压小命中区。 +- **验收**:至少 375、390、1024、1280 视口对照;无整页横滚,表格仍可横滚;在实际盘宽下验证文字和触控尺寸、西洋拥挤标签/命中归属。报告与校正盘尺寸不变。 + +### T6 · 文档、回归与交付 + +- UI 同提交更新 `frontend/DESIGN.md`,新文案核对并更新 `frontend/docs/VOICE.md`,用户变化入 `CHANGELOG.md`(Skill 不 bump)。 +- 更新 BUG 历史与 PROGRESS;不能自动化的项目进入 `docs/testing/chart-surface-polish-20260929.md`,实际环境缺口入 `BLOCKED.md`。 +- `tsc --noEmit` 零错、lint 零 error、相关套件 fail/cancelled 均为零;全量 `npm test` 与同环境基线比测试名称、失败名单和退出码。`npm run build` 后 `/` 仍 Static,首屏 gzip 同口径变化 ±2%。 +- 若修改 Python 或 Python 合同测试,定向与 `run_quality_gate.py --profile quick` 全量必跑;即使仅前端,本单也运行 quick 以防漏掉前端源码的 Python 合同。 +- 文档推送前运行 `tests/test_repo_privacy_markers.py`。代码完成先交 Claude 独立验收,不自行 push;用户授权推 staging 后才核对门禁、部署 SHA,不把文档已推送当作实现已上线。 + +### 已定位的回归文件与旧断言 + +| 文件 | 本轮重点 | +| --- | --- | +| `frontend/tests/chart-page-view.test.tsx` | 参数行无引擎、全盘型遍历;原西洋 `max-width:520px` 断言改为本单目标,写三栏理由 | +| `frontend/tests/chart-varga-table.test.tsx` | 真实分盘行/D1冻结仍保留;旧六列断言改为 D9 六列、其他 Dn 五列及能力缺失分支;宫位类回归 | +| `frontend/tests/chart-view-varga-dignity.test.ts` | 入旺/自宫/落陷规则子集与未命中含义不变 | +| `tests/test_chart_page_vedic_tables_contract.py` | 同源规则表合同保留;如增加 D9 同位对照,使用真实 golden 和原生函数,输入岁差明确 | +| `frontend/tests/chart-planet-glyphs.test.tsx` | 原 `markup.match(/逆/g)` 只允许一处“逆”,新增 title 后不可继续全 markup 数字数;改为验证可见文字与说明节点各自语义,三栏列明,不能直接删除测试 | +| `frontend/tests/chart-page-mobile-width-20260929.test.ts` | 保留 BUG-1083 的 minmax/overflow 合同;新增页面限定的盘宽约束 | +| `frontend/src/lib/report-chart-export.tsx` 的相关测试 | 共享 drawing 仍 hook-free、静态导出安全、数字 height,不能出现 SVG `height="auto"`(BUG-906) | + +西洋 hover 必须实测可见文字层而不只是透明命中层;现有 `western-wheel-svg.tsx` 先画 hit group、后画 caption group,上层文字可能截走指针。不得为了 tooltip 破坏字符命中归属和原键盘按钮语义。 + +## 6. 让步顺序 + +1. 可先不扩展到本单没有展示缺陷的报告正文;不得扩大为全站技术词清理。 +2. 若触屏复杂浮层增加冲突,改用现有选中详情承载等价说明,不新造复杂手势。 +3. 若缩小后破坏可读性,减小缩幅,不能牺牲图标文字或命中区域。 +4. 去引擎、真实能力边界、宫位左对齐、鼠标图标解释与手机不溢出不可让步。 + +## 7. 开工前置命令 + +每次 Git 操作前都先 `git status -sb`,确认在自己的工作树;禁止 stash。以下路径按实际环境调整,不在主检出写代码。 + +```bash +git status -sb +git fetch origin --prune +git status -sb +git worktree add -b codex/chart-surface-polish-20260929 .worktrees/chart-surface-polish-20260929 origin/staging +cd .worktrees/chart-surface-polish-20260929 +git status -sb +python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 +# 必读 AGENTS、frontend/AGENTS、DESIGN、VOICE;检索 BUG_HISTORY 和全部相关源码合同。 +# 核对实际 Node/Python 环境,在同基线独立树保存全量测试名称/失败清单、构建与 gzip。 +``` + +## 8. BUG 编号起点 + +写作时 `docs/BUG_HISTORY.md` 最大号 **BUG-1097**。本单从 **BUG-1098** 起登记已确认的展示缺陷;实现前再次核对,若被占用按当前最大号连续顺延并同步任务书/进度引用。新增功能/产品取舍不为了凑编号全部记 Bug。 diff --git a/docs/tasks/TASK-chart-surface-polish-fix-20260929.md b/docs/tasks/TASK-chart-surface-polish-fix-20260929.md new file mode 100644 index 00000000..d824f6aa --- /dev/null +++ b/docs/tasks/TASK-chart-surface-polish-fix-20260929.md @@ -0,0 +1,24 @@ +# 星盘展示独立验收修复单(2026-09-29) + +## 基线与授权 + +原任务 `TASK-chart-surface-polish-20260929.md` 已获实施授权。本单是同一工作区验收发现的收尾,不扩大算法或发布范围;基线仍为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,分支 `codex/chart-surface-polish-20260929`。不 commit/push/deploy,不改 workflow/数据库/依赖。 + +## 事故实证与根因 + +1. 独立审查发现印度骨架和结果已同宽,但图例带使高度不同。Chrome 六视口追加测量均失败:骨架 400×400,结果 400×452,外框增高随盘宽变化。证据 `artifacts/chart-surface-polish-browser-20260929/run-2026-09-29T10-23-27-737Z/results.json`。此比例差在基线已有,但原任务 T5“揭幕后不跳尺寸”不能仅按宽度通过。 +2. 全盘型去引擎测试中 Bhava 使用失败占位,Transit 复用本命响应;已有独立成功 golden 渲染未显式断言参数表和去引擎,存在否定断言覆盖不足。 +3. 触控 leave 怀疑项已用真实可信事件链验证:Chrome click 在 touch leave 之后,说明在 1500ms 后仍在,当前没有实测失败,不为假设改动生产事件链。实体手机仍待验。 + +## 决策与修复 + +- 仅 `/chart` 的 `.chart-page-skeleton.chart-page-vedic-plot` 设置 `aspect-ratio:400/452`,预留图例带。内部空 SVG 保持方盘,不改共享 SVG、viewBox、静态导出、报告/校正,不画虚假的骨架图例。 +- 在 Bhava/Transit 真实成功 golden 既有测试中新增参数表、岁差、交点存在及引擎不存在的断言,旧断言保留。 +- 串行顺序:初轮浏览器结束 → 主会话修改 CSS/尺寸合同、数据代理修改两成功态测试 → 全部冻结 → 浏览器重跑与 Linux final-r2 新快照全套。r1 不覆盖、不冒充最终证据。 + +## 验收与红线 + +- 六视口盘主体尺寸不变,骨架/结果容器与外框等高,说明区起点一致;不要求有无图例的两个 SVG 元素自身等高。 +- 重新执行完整浏览器交互/命中/静态回归;主会话定向与 Linux tsc/lint/npm test/build、Python 定向/quick。失败名、多重集合、cancelled、退出码与首页 gzip 同基线比较。 +- 旧失败日志保留,未新增回归不等于全量全绿;既有 29 项加载失败根因未定,不顺带改依赖或测试工具。 +- 更新 DESIGN/PROGRESS/真人清单;无受控登录/实体手机仍写 BLOCKED,不借凭据。结果汇总入主任务 PROGRESS,不重复创建 Bug 编号(本单是原 T5 验收缺口,不是新报线上事故)。 diff --git a/docs/testing/chart-surface-polish-20260929.md b/docs/testing/chart-surface-polish-20260929.md new file mode 100644 index 00000000..63cebc1c --- /dev/null +++ b/docs/testing/chart-surface-polish-20260929.md @@ -0,0 +1,41 @@ +# 星盘展示收口 · 真人验收清单(2026-09-29) + +对应任务书:`docs/tasks/TASK-chart-surface-polish-20260929.md`。自动化证据与最终结论以同名 PROGRESS 为准。本轮仅本地实现,未推送、未部署;以下线上项目均待部署后在受控测试账户完成,不借用个人登录态或他人凭据。 + +## 环境与隐私 + +- 使用明确虚构资料的测试人物,不录入或截图真实个人资料、账户标识、Cookie、JWT、密钥。 +- 桌面 Chrome / Edge 与实体手机各走一次;手机至少覆盖接近 375/390px 的窄屏。记录浏览器/系统版本和视口,不记录身份信息。 +- 本地 Chrome + golden harness 验证了真实组件、字体和样式,但不是完整登录、服务端请求或实体手机/读屏验收。 + +## 展示与表格 + +- [ ] 切换全部盘型:参数区没有“引擎”或“Swiss Ephemeris(本站)”,原岁差、交点和时间精度提醒仍在。 +- [ ] D9 保留“庙旺”“同位”;其他分盘保留三档“庙旺”,无“同位”表头或空白占位列。 +- [ ] 未命中庙旺/同位仍按既有空值呈现,不因为本盘全未命中而隐藏受支持列;分盘上升庙旺为“不适用”。 +- [ ] 旧页面内存快照若缺能力声明,整列保守隐藏;刷新得到新响应后按新能力显示,不串人物资料。 +- [ ] 本命、月亮、D9、D10、Bhava、行运:每个宫位标题与值左边线一致。宿主、pada、速度、换宫等原居中列保持原样。 +- [ ] 窄屏整页不横滚,宽表只在表内横滚,切盘条仍可横滑。 + +## 图标说明 + +- [ ] 鼠标停在印度行星符号、逆行短线、As、星座编号及拥挤宫位的 +N 上,盘下显示对应事实说明,离开清除。 +- [ ] Tab 能到每个可解释标记,焦点可见;focus、Enter/Space 与鼠标获得同文案,Esc/失焦关闭说明。 +- [ ] 手机轻点可读说明,不靠 hover;轻点盘内空白关闭。拥挤宫位不误触相邻星体,说明不盖住绘图。 +- [ ] 西洋行星字形、度数字段、逆行提示、透明可点区域的说明与选中对象一致;星座名、宫号、ASC/MC 解释对象正确。 +- [ ] 西洋点选仍筛选相位,再点同星仍取消;Esc 仅关闭说明,不清除已选相位对象。 +- [ ] 切盘、切人物、更新出生资料后旧说明不残留。返回页面、慢网或请求失败不显示前一人物盘面。 +- [ ] 读屏能读到名称/事实,不将整张 SVG 当单张图而吞掉可聚焦目标;连续操作的 live-region 播报不过度重复。 +- [ ] 默认提示为“停在或轻点盘内标记可查看说明。”,没有运势判断或新增推测度数。 + +## 尺寸、等待与共享绘图 + +- [ ] ≥1024px:印度实际绘图区不超过 440px,西洋不超过 480px;参数卡与下方表格没有一起缩小。 +- [ ] 375/390/480/1023px:与改动前同布局宽度,不额外缩盘;字号没有另行缩小。 +- [ ] 冷打开、切人物或慢网时,盘位骨架与结果宽度一致,印度盘框高度与说明句起点也一致,出现结果时不跳动;骨架下方为图例预留空位而不绘虚假图例。失败仍退出等待态,不新增参数卡/表格骨架。 +- [ ] 报告、校正里的共享盘大小及静态行为不变;报告导出图片仍正常,无新增交互属性依赖。 + +## 已知边界 + +- 本轮基线已有个别印度盘多星同宫的字形边缘超出 SVG 边界;本地同视口对照未新增裁切,不能把“无新增裁切”写成“全部字形无裁切”。这项既有视觉问题另行记录,不扩改本轮共享布局算法。 +- 实体触屏、读屏与受控登录链路尚未执行。验收时任何不通过项记录盘型和操作步骤,不附真实个人资料。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ccb8731d..93af7cff 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1011,11 +1011,13 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 ### 排盘参数表 -参数是**一张键值表**(`.chart-page-params-table`),八行:出生时间(含校正状态)、出生地(含经纬)、岁差、交点、上升、月宿、当前大运、引擎。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开。 +参数是**一张键值表**(`.chart-page-params-table`),七行:出生时间(含校正状态)、出生地(含经纬)、岁差、交点、上升、月宿、当前大运。2026-09-29 产品决定移除引擎品牌行及专用展示投影;内部计算来源和许可证保留,不以别的品牌词替代。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开。 **不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.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)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。 +桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起,印度内部 `.chart-page-vedic-plot` 绘图区上限 440px 并居中,外框 padding 不计入盘宽;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位呼吸,失败后退出,参数表不新增骨架。 + +**符号说明(2026-09-29)。** `/chart` 开启共享 SVG 的可选交互模式:鼠标悬停、键盘聚焦与手机轻点都在盘下预留说明区显示同一事实说明,不覆盖邻近图标;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。 盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、盘型 chip 均 44px。 @@ -1068,12 +1070,12 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | 盘型 | 列 | 说明 | | --- | --- | --- | | 本命 | 上面八列 | 不变(与基线渲染逐字节相同) | -| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺 · 同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」;同位 = 与本命同星座(Vargottama)。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | +| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」,上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | | 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) | | Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) | | 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 | -说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。 +说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。 ### 三套坐标系 @@ -1099,7 +1101,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 ### 西洋盘 Tab(2026-09-25) -盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、`max-width: 520px`、水平居中。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。 +盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、窄屏 `max-width: 520px`、≥1024px 桌面上限 480px、水平居中(2026-09-29 仅桌面收紧)。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。 星座环按元素填色,token 在 `:root` 与两套暗色里各一份: diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 71d323f3..0d4ab220 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -105,6 +105,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 这四句只在活动行里,是「流式生成中」的一部分:不进正文、不进历史、刷新后不出现;只往前走,不回头;不写秒数、不写「请稍候」,不承诺多久。 +## 星盘图标说明与能力列(2026-09-29) + +- 盘面不显示引擎品牌,也不替换成「自研引擎」「本站引擎」。岁差、交点等计算口径与准确性边界照旧。 +- 图标说明只写名称和已有事实:上升、行星、逆行、星座名称/编号、宫位、ASC/MC 等。没有可靠度数就不写度数,不加吉凶、性格或个人运势解释。 +- 鼠标悬停、键盘聚焦、手机轻点展示同一份说明;说明在盘外预留区域,不遮住旁边图标。关闭说明不改变西洋相位选择。默认提示为「停在或轻点盘内标记可查看说明。」 +- 分盘庙旺仅指入旺、自宫、落陷三档;未命中写「—」,上升写「不适用」。同位仅 D9 展示,其他分盘不留空列。缺能力声明不装作已支持。 + ## 星盘空状态、0 年大运、星历切日期 这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」: diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2bb91c98..f88eb2ad 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5119,7 +5119,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class /* A body the Bhava boundaries move to another house. */ .chart-page-planet-table tr.is-shifted { background: var(--color-selected); } .chart-page-waiting-plot { - width: min(100%, 400px); + width: 100%; min-width: 0; } .chart-page-skeleton { @@ -5141,6 +5141,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class padding: var(--space-3); } .chart-page-vedic-stage .personal-report-chart-svg { margin-top: 0; } +.chart-page-vedic-plot, +.chart-page-symbol-help { + width: 100%; + min-width: 0; +} +.chart-page-vedic-plot { margin-inline: auto; } +/* Reserve the result's legend band without changing the shared empty drawing. */ +.chart-page-skeleton.chart-page-vedic-plot { aspect-ratio: 400 / 452; } +/* Keep the explanation outside the drawing so it cannot cover nearby symbols. */ +.chart-page-note.chart-page-symbol-note { + min-height: 2.8em; + margin: var(--space-2) 0 0; + line-height: 1.4; +} +.chart-page-symbol-hit { fill: transparent; stroke: none; } +.chart-page-symbol-target { cursor: help; } +.chart-page-symbol-target:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} /* Only the small type is lifted for the chart page's narrow stage. Raising every `text` would also resize the planet symbols, which are already sized for this width. */ @@ -5247,6 +5267,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .ephemeris-transit-glyph.is-node { color: var(--color-planet-node); } .chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; } .chart-page-planet-table td.chart-page-planet-mid { text-align: center; } +/* The natal/Moon eight-column markup stays frozen; only its house column changes. */ +.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) { + text-align: left; +} /* Figures line up column-wise instead of drifting with glyph width. */ .chart-page-planet-num { font-variant-numeric: tabular-nums; } .chart-page-boundary { @@ -5513,6 +5537,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class gap: var(--space-6); align-items: start; } + /* Limit the drawing, not its padded stage or the parameter/table column. */ + .chart-page-vedic-plot { max-width: 440px; } + .chart-page-western-wheel { max-width: 480px; } /* 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, diff --git a/frontend/src/components/chart-page/chart-page-skeleton.tsx b/frontend/src/components/chart-page/chart-page-skeleton.tsx index ade8efed..cc8b2950 100644 --- a/frontend/src/components/chart-page/chart-page-skeleton.tsx +++ b/frontend/src/components/chart-page/chart-page-skeleton.tsx @@ -4,12 +4,14 @@ import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "./western-wheel-svg"; /** Only chart positions opt in; the shared page shell remains static. */ export function ChartPageSkeleton({ copy, western = false }: { copy: string; western?: boolean }) { return ( - <div className="chart-page-waiting-plot" aria-busy="true" role="status"> - <div className="chart-page-skeleton" aria-hidden="true"> - {western ? <WesternWheelSvg western={EMPTY_WESTERN_WHEEL} skeleton /> - : <VedicChartSvg chart={EMPTY_NORTH_INDIAN_CHART} ariaLabel={copy} skeleton />} + <div className={western ? "chart-page-western-layout" : "chart-page-vedic-layout"}> + <div className={`chart-page-waiting-plot ${western ? "chart-page-western-wheel" : "chart-page-vedic-stage"}`} aria-busy="true" role="status"> + <div className={western ? "chart-page-skeleton" : "chart-page-skeleton chart-page-vedic-plot"} aria-hidden="true"> + {western ? <WesternWheelSvg western={EMPTY_WESTERN_WHEEL} skeleton /> + : <VedicChartSvg chart={EMPTY_NORTH_INDIAN_CHART} ariaLabel={copy} skeleton />} + </div> + <p className="chart-page-note chart-page-symbol-note">{copy}</p> </div> - <p className="chart-page-note">{copy}</p> </div> ); } diff --git a/frontend/src/components/chart-page/chart-param-table.tsx b/frontend/src/components/chart-page/chart-param-table.tsx index c1629e4d..7fc43254 100644 --- a/frontend/src/components/chart-page/chart-param-table.tsx +++ b/frontend/src/components/chart-page/chart-param-table.tsx @@ -25,7 +25,6 @@ export function ChartParamTable({ view }: { view: ChartViewOk }) { ["上升", profile.ascendantSignLabel], ["月宿", `${profile.moonNakshatra} 第${profile.moonPada}足`], ["当前大运", profile.currentDashaLabel], - ["引擎", profile.engineName], ]; return ( <dl className="chart-page-params-table"> diff --git a/frontend/src/components/chart-page/chart-symbol-help.tsx b/frontend/src/components/chart-page/chart-symbol-help.tsx new file mode 100644 index 00000000..c3396527 --- /dev/null +++ b/frontend/src/components/chart-page/chart-symbol-help.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + +type Explain = (text: string | null) => void; +type Props = { + resetKey: string; + children: ReactNode | ((explain: Explain) => ReactNode); +}; + +/** The drawing only supplies facts. All transient help stays on the chart page. */ +export function ChartSymbolHelp({ resetKey, children }: Props) { + return <SymbolHelpSession key={resetKey}>{children}</SymbolHelpSession>; +} + +function helpTarget(target: EventTarget | null): Element | null { + let element = target instanceof Element ? target : null; + while (element) { + if (element.hasAttribute("data-chart-help")) return element; + element = element.parentNode instanceof Element ? element.parentNode : null; + } + return null; +} + +function SymbolHelpSession({ children }: Pick<Props, "children">) { + const [help, setHelp] = useState<string | null>(null); + const explainTarget = (target: EventTarget | null) => { + setHelp(helpTarget(target)?.getAttribute("data-chart-help") ?? null); + }; + return ( + <div + className="chart-page-symbol-help" + onPointerOver={(event) => explainTarget(event.target)} + onPointerMove={(event) => explainTarget(event.target)} + onPointerOut={(event) => { + if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setHelp(null); + }} + onPointerLeave={() => setHelp(null)} + onFocus={(event) => explainTarget(event.target)} + onBlur={() => setHelp(null)} + onClick={(event) => explainTarget(event.target)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setHelp(null); + event.stopPropagation(); + } else if (event.key === "Enter" || event.key === " ") { + const target = helpTarget(event.target); + if (target) { + event.preventDefault(); + explainTarget(target); + } + } + }} + > + {typeof children === "function" ? children(setHelp) : children} + <p className="chart-page-note chart-page-symbol-note" role="status" aria-live="polite" aria-atomic="true"> + {help ?? "停在或轻点盘内标记可查看说明。"} + </p> + </div> + ); +} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 0b05a2eb..c074c134 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -30,6 +30,7 @@ import { ChartParamTable } from "./chart-param-table"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { VARGA_DIGNITY_LABEL } from "@/lib/chart-view-varga"; import { ChartPageSkeleton } from "./chart-page-skeleton"; +import { ChartSymbolHelp } from "./chart-symbol-help"; /** The same coloured symbol the chart draws, so a row and a house read as one body. */ function BodyGlyph({ name }: { name: string }) { @@ -136,7 +137,9 @@ function NatalTable({ planets, ascendant, ascendantHouse, caption }: { ); } -function VargaRow({ row }: { row: ChartViewVargaRow }) { +type VargaColumns = { supportsDignity: boolean; supportsVargottama: boolean }; + +function VargaRow({ row, supportsDignity, supportsVargottama }: { row: ChartViewVargaRow } & VargaColumns) { const key = planetGlyphKey(row.name); return ( <tr> @@ -145,35 +148,39 @@ function VargaRow({ row }: { row: ChartViewVargaRow }) { {row.retrograde ? <span className="chart-page-planet-abbr"> 逆</span> : null} </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">{row.house}宫</td> + <td>{row.house}宫</td> <td>{row.natalSignLabel}</td> - <td className="chart-page-planet-mid">{row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"}</td> - <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td> + {supportsDignity && <td className="chart-page-planet-mid">{row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"}</td>} + {supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>} </tr> ); } -function VargaAscendantRow({ row }: { row: ChartViewVargaAscendantRow }) { +function VargaAscendantRow({ row, supportsDignity, supportsVargottama }: { row: ChartViewVargaAscendantRow } & VargaColumns) { return ( <tr> <th scope="row"> <PlanetCell name={null} label="上升" abbr="As" /> </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">1宫</td> + <td>1宫</td> <td>{row.natalSignLabel}</td> - <td className="chart-page-planet-mid">—</td> - <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td> + {supportsDignity && <td className="chart-page-planet-mid">不适用</td>} + {supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>} </tr> ); } /** - * A divisional chart's own table (BUG-1076). Six columns, not the natal eight: - * nakshatra, its lord and pada are properties of the D1 longitude and would be - * wrong data here, so they are not shown at all. + * A divisional chart's own table (BUG-1076). Capability columns are declared + * by the server, independently of their result values. Nakshatra, its lord and + * pada belong to the D1 longitude and would be wrong data here. */ export function VargaTable({ varga }: { varga: ChartViewVarga }) { + const columns: VargaColumns = { + supportsDignity: varga.supportsDignity === true, + supportsVargottama: varga.supportsVargottama === true, + }; return ( <div className="chart-page-planet-table-wrap"> <table className="chart-page-planet-table"> @@ -184,13 +191,13 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { <th scope="col">分盘星座</th> <th scope="col">宫位</th> <th scope="col">本命星座</th> - <th scope="col">庙旺</th> - <th scope="col">同位</th> + {columns.supportsDignity && <th scope="col">庙旺</th>} + {columns.supportsVargottama && <th scope="col">同位</th>} </tr> </thead> <tbody> - {varga.ascendant ? <VargaAscendantRow row={varga.ascendant} /> : null} - {(varga.rows ?? []).map((row) => <VargaRow key={row.name} row={row} />)} + {varga.ascendant ? <VargaAscendantRow row={varga.ascendant} {...columns} /> : null} + {(varga.rows ?? []).map((row) => <VargaRow key={row.name} row={row} {...columns} />)} </tbody> </table> </div> @@ -226,8 +233,8 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> <td className="chart-page-planet-mid">{row.retrograde ? "逆" : "顺"}</td> - <td className="chart-page-planet-mid">{row.natalHouse}宫</td> - <td className="chart-page-planet-mid">{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}</td> + <td>{row.natalHouse}宫</td> + <td>{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}</td> </tr> ); })} @@ -261,8 +268,8 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { <PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} /> </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">{row.rashiHouse}宫</td> - <td className="chart-page-planet-mid">{row.bhavaHouse}宫</td> + <td>{row.rashiHouse}宫</td> + <td>{row.bhavaHouse}宫</td> <td className="chart-page-planet-mid">{row.shifted ? "是" : "否"}</td> </tr> ); @@ -306,7 +313,11 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: { return ( <div className="chart-page-vedic-layout"> <div className="chart-page-vedic-stage"> - <VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} /> + <ChartSymbolHelp resetKey={JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, ariaLabel, ascendantHouse, chart])}> + <div className="chart-page-vedic-plot"> + <VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} interactive /> + </div> + </ChartSymbolHelp> </div> <div className="chart-page-params"> <ChartParamTable view={view} /> diff --git a/frontend/src/components/chart-page/chart-western-tab.tsx b/frontend/src/components/chart-page/chart-western-tab.tsx index 6d764e60..5b1e8123 100644 --- a/frontend/src/components/chart-page/chart-western-tab.tsx +++ b/frontend/src/components/chart-page/chart-western-tab.tsx @@ -6,6 +6,7 @@ import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract" import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; import { formatDegreeMinutes } from "./chart-format"; import { WesternWheelSvg } from "./western-wheel-svg"; +import { ChartSymbolHelp } from "./chart-symbol-help"; const STELLIUM_COUNT = 3; @@ -50,6 +51,11 @@ export function ChartWesternTab({ view: ChartViewOk; initialSelectedId?: string | null; }) { + const resetKey = JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, view.western]); + return <WesternTabSession key={resetKey} view={view} initialSelectedId={initialSelectedId} />; +} + +function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; initialSelectedId: string | null }) { const western = view.western; const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId); if (western.status === "unavailable") { @@ -75,11 +81,16 @@ export function ChartWesternTab({ <div className="chart-page-western"> <div className="chart-page-western-layout"> <div className="chart-page-western-wheel"> - <WesternWheelSvg - western={western} - selectedLabel={selected?.label ?? null} - onSelectPlanet={toggle} - /> + <ChartSymbolHelp resetKey="western"> + {(explain) => ( + <WesternWheelSvg + western={western} + selectedLabel={selected?.label ?? null} + onSelectPlanet={toggle} + onExplain={explain} + /> + )} + </ChartSymbolHelp> </div> <div className="chart-page-western-side"> <ul className="chart-page-western-chips"> diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index 780e0e93..7fda4a65 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent } from "react"; +import type { MouseEvent, PointerEvent } from "react"; import type { ChartViewWesternOk } from "@/lib/chart-view-contract"; import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels"; @@ -79,11 +79,13 @@ export function WesternWheelSvg({ skeleton = false, selectedLabel = null, onSelectPlanet, + onExplain, }: { western: WesternWheelModel; skeleton?: boolean; selectedLabel?: string | null; onSelectPlanet?: (id: string) => void; + onExplain?: (text: string | null) => void; }) { const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern; const asc = western.ascendantLongitude; @@ -115,15 +117,38 @@ export function WesternWheelSvg({ ); return { id: placement.id, x: display.x, y: display.y }; }); - function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) { + function nearestId(event: MouseEvent<SVGElement>, fallbackId: string): string { const svg = event.currentTarget.ownerSVGElement; const matrix = svg?.getScreenCTM(); - if (!svg || !matrix || !onSelectPlanet) return; + if (!svg || !matrix) return fallbackId; const point = svg.createSVGPoint(); point.x = event.clientX; point.y = event.clientY; const local = point.matrixTransform(matrix.inverse()); - onSelectPlanet(nearestWesternPlanetId(hitPoints, local.x, local.y) ?? fallbackId); + return nearestWesternPlanetId(hitPoints, local.x, local.y) ?? fallbackId; + } + function planetDescription(id: string): string | null { + const planet = western.planets.find((body) => body.id === id); + return planet ? `${planet.label} · ${planet.retrograde ? "逆行" : "顺行"}` : null; + } + function explainNearest(event: PointerEvent<SVGElement>, fallbackId: string) { + if (!onExplain) return; + event.stopPropagation(); + onExplain(planetDescription(nearestId(event, fallbackId))); + } + function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) { + const id = nearestId(event, fallbackId); + onSelectPlanet?.(id); + if (onExplain) { + event.stopPropagation(); + onExplain(planetDescription(id)); + } + } + function helpProps(text: string) { + return onExplain ? { + className: "chart-page-symbol-target", role: "button", tabIndex: 0, + "aria-label": text, "data-chart-help": text, + } : {}; } const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude])); longitudeByLabel.set("上升", asc); @@ -133,7 +158,7 @@ export function WesternWheelSvg({ <svg viewBox={`0 0 ${WESTERN_SIZE} ${WESTERN_SIZE}`} width="100%" - role="img" + role={skeleton ? "img" : "group"} aria-label={skeleton ? undefined : "回归黄道本命盘,上升在左,宫位按 Placidus"} aria-hidden={skeleton || undefined} data-skeleton={skeleton ? "true" : undefined} @@ -159,25 +184,12 @@ export function WesternWheelSvg({ const edge = westernScreenAngle(index * 30, asc); const inner = westernPolar(WESTERN_R_ZODIAC, edge); const outer = westernPolar(WESTERN_R_OUTER, edge); - const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); - return ( - <g key={`${sign}-spoke`}> - <line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" /> - {!skeleton && ( - <text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-sign-label"> - {signZh(sign)} - </text> - )} - </g> - ); + return <line key={`${sign}-spoke`} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />; })} - {!skeleton && western.houses.map((house, index) => { - const next = western.houses[(index + 1) % western.houses.length] ?? house; + {!skeleton && western.houses.map((house) => { const angle = westernScreenAngle(house.longitude, asc); const inner = westernPolar(WESTERN_R_ASPECT, angle); const outer = westernPolar(WESTERN_R_ZODIAC, angle); - const mid = houseMidLongitude(house.longitude, next.longitude); - const label = houseLabels[index]!; const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10; return ( <g key={house.house}> @@ -188,17 +200,6 @@ export function WesternWheelSvg({ y2={outer.y} className={axis ? "chart-page-western-house is-axis" : "chart-page-western-house"} /> - <text - x={label.x} - y={label.y} - textAnchor="middle" - dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} - className="chart-page-western-house-label" - data-house={house.house} - data-longitude={fmt(mid)} - > - {house.house} - </text> </g> ); })} @@ -238,12 +239,21 @@ export function WesternWheelSvg({ role="button" tabIndex={0} aria-pressed={selected} - aria-label={planet.label} + aria-label={onExplain ? planetDescription(planet.id) ?? planet.label : planet.label} + onPointerOver={(event) => explainNearest(event, planet.id)} + onPointerMove={(event) => explainNearest(event, planet.id)} + onPointerLeave={() => onExplain?.(null)} + onFocus={(event) => { + if (!onExplain) return; + event.stopPropagation(); + onExplain(planetDescription(planet.id)); + }} onClick={(event) => selectNearest(event, planet.id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onSelectPlanet?.(planet.id); + onExplain?.(planetDescription(planet.id)); }} > <line @@ -281,6 +291,9 @@ export function WesternWheelSvg({ data-longitude={fmt(placement.longitude)} data-display-longitude={fmt(placement.displayLongitude)} aria-hidden="true" + onPointerOver={(event) => explainNearest(event, planet.id)} + onPointerMove={(event) => explainNearest(event, planet.id)} + onPointerLeave={() => onExplain?.(null)} onClick={(event) => selectNearest(event, planet.id)} > <text @@ -296,18 +309,52 @@ export function WesternWheelSvg({ </g> ); })} + {/* Fixed labels also sit above hit circles: their visible ink must keep + its own explanation rather than selecting a nearby planet. */} + {!skeleton && SIGN_ORDER.map((sign, index) => { + const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); + return ( + <g key={`${sign}-label`} {...helpProps(`${signZh(sign)}座`)}> + <text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-sign-label" pointerEvents="bounding-box"> + {signZh(sign)} + </text> + </g> + ); + })} + {!skeleton && western.houses.map((house, index) => { + const next = western.houses[(index + 1) % western.houses.length] ?? house; + const label = houseLabels[index]!; + return ( + <g key={`house-${house.house}-label`} {...helpProps(`${house.house} · 第 ${house.house} 宫`)}> + <text + x={label.x} + y={label.y} + textAnchor="middle" + dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} + className="chart-page-western-house-label" + data-house={house.house} + data-longitude={fmt(houseMidLongitude(house.longitude, next.longitude))} + pointerEvents="bounding-box" + > + {house.house} + </text> + </g> + ); + })} {!skeleton && angles.map((angle) => ( - <text - key={angle.caption} - x={angle.x} - y={angle.y} - textAnchor="middle" - dominantBaseline="central" - style={{ fontSize: WESTERN_FONT_SIZE }} - className="chart-page-western-angle" - > - {angle.caption} - </text> + <g key={angle.caption} {...helpProps(`${angle.caption} · ${angle.caption === "ASC" ? "上升" : "中天"}`)}> + <text + x={angle.x} + y={angle.y} + textAnchor="middle" + dominantBaseline="central" + style={{ fontSize: WESTERN_FONT_SIZE }} + className="chart-page-western-angle" + pointerEvents="bounding-box" + > + {angle.caption} + </text> + </g> ))} </svg> ); diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index b00bea6f..e307d423 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -19,6 +19,7 @@ import { useId } from "react"; import type { ChartV1 } from "@/lib/personal-report-contract"; +import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; import { CHART_SIZE, HOUSE_NUMBERS, @@ -30,6 +31,8 @@ import { import { ASCENDANT_COLOR_CLASS, ASCENDANT_MARK, + ASCENDANT_NAME_ZH, + PLANET_NAME_ZH, PLANET_COLOR_CLASS, PLANET_GLYPH, PLANET_GLYPH_ZH, @@ -70,6 +73,8 @@ export const EMPTY_NORTH_INDIAN_CHART: NorthIndianChartModel = { houses: [] }; interface VedicChartSvgProps { skeleton?: boolean; + /** Chart-page enhancement only; reports, rectification and exports stay static. */ + interactive?: boolean; chart: NorthIndianChartModel; ariaLabel: string; highlightHouseNumbers?: ReadonlySet<number>; @@ -165,7 +170,29 @@ function tokenKey(token: ChartToken, index: number): string { return `${token.label}-${index}`; } -function OccupantGlyph({ token, x, y }: { token: ChartToken; x: number; y: number }) { +function tokenDescription(token: ChartToken): string { + if (token.kind === "planet") { + return `${PLANET_NAME_ZH[token.key]} · ${token.retrograde ? "逆行(符号下的横线)" : "顺行"}`; + } + if (token.kind === "ascendant") return `${ASCENDANT_MARK} · ${ASCENDANT_NAME_ZH}`; + if (token.kind === "overflow") return `${token.label} · 这一宫还有 ${token.label.slice(1)} 个标记未展开,可查看下方表格。`; + return token.label; +} + +function OccupantGlyph({ token, x, y, interactive }: { token: ChartToken; x: number; y: number; interactive: boolean }) { + const drawing = <OccupantGlyphDrawing token={token} x={x} y={y} />; + if (!interactive) return drawing; + const description = tokenDescription(token); + return ( + <g className="chart-page-symbol-target" role="button" tabIndex={0} aria-label={description} data-chart-help={description}> + <title>{description} + + {drawing} + + ); +} + +function OccupantGlyphDrawing({ token, x, y }: { token: ChartToken; x: number; y: number }) { if (token.kind === "ascendant") { return ( @@ -285,7 +313,8 @@ export function VedicChartSvg(props: VedicChartSvgProps) { } /** Shared, hook-free drawing for the reader and offline Markdown export. */ -export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1 }: VedicChartSvgProps & { chartId: string }) { +export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1, interactive: enableHelp = false }: VedicChartSvgProps & { chartId: string }) { + const interactive = enableHelp && !skeleton; const chart = skeleton ? EMPTY_NORTH_INDIAN_CHART : suppliedChart; const retrograde = retrogradeKeys(chart); const tokensByHouse = new Map(); @@ -307,7 +336,7 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh {ordinal !== null && ( - - {ordinal} - + {interactive && } + + {ordinal} + + )} - + ); diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 0a6aeefe..2fcc81eb 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -105,6 +105,10 @@ export const chartViewVargaSchema = z.object({ // D2 and up only. D1 keeps the natal eight-column table (`vedic.planets`). ascendant: chartViewVargaAscendantRowSchema.optional(), rows: z.array(chartViewVargaRowSchema).optional(), + // Older module-cache snapshots have no declaration: consumers must hide the + // corresponding column unless its server-projected capability is exactly true. + supportsDignity: z.boolean().optional(), + supportsVargottama: z.boolean().optional(), }); /** @@ -258,7 +262,6 @@ export const chartViewProfileSchema = z.object({ nodeModeLabel: z.string().min(1), birthTimeStatus: z.string().min(1), birthTimeStatusLabel: z.string().min(1), - engineName: z.string().min(1), moonNakshatra: z.string().min(1), moonPada: z.number().int().min(1).max(4), ascendantSign: z.string().min(1), diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 8982cf87..d8b8c67f 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -314,13 +314,20 @@ function buildVargas( const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro); if (mapped) { const ascSign = text(record(raw.ascendant)?.sign)!; + const table = division === 1 ? null : vargaRows(ascSign, raw, natalRetro, natal); vargas.push({ id, title: id, meaning: mapped.meaning, division: mapped.division, chart: mapped.chart, - ...(division === 1 ? {} : vargaRows(ascSign, raw, natalRetro, natal)), + ...(table ? { + ...table, + // A null dignity or false same-sign result is still a valid result. + // Availability follows validated source rows, never positive hits. + supportsDignity: table.rows.length > 0, + supportsVargottama: division === 9 && (table.rows.length > 0 || table.ascendant !== undefined), + } : {}), }); continue; } @@ -819,7 +826,6 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { nodeModeLabel: nodeModeLabel(nodeMode), birthTimeStatus: profile.birthTimeStatus ?? "reported", birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"), - engineName: "Swiss Ephemeris(本站)", moonNakshatra: text(moon.nakshatra) ?? "—", moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))), ascendantSign: text(ascendant?.sign) ?? "—", diff --git a/frontend/tests/chart-bhava.test.tsx b/frontend/tests/chart-bhava.test.tsx index 3e64e652..c90b870d 100644 --- a/frontend/tests/chart-bhava.test.tsx +++ b/frontend/tests/chart-bhava.test.tsx @@ -85,10 +85,25 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses" const markup = render(view); assert.ok(markup.includes(CHART_VIEW_COPY.bhavaNote)); + assert.match(markup, /
/); + assert.match(markup, /
岁差<\/dt>/); + assert.match(markup, /
交点<\/dt>/); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎Bhava · Sripati<\/caption>/); assert.match(markup, /宫头与宫界<\/caption>/); assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length); assert.equal((markup.match(/是<\/td>/g) ?? []).length, shifted.length); + const body = markup.match(/([\s\S]*?)<\/tbody>/)![1]!; + const rows = [...body.matchAll(/]*>([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 9); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/]*)>([\s\S]*?)<\/td>/g)]; + for (const index of [1, 2]) { + assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment"); + assert.match(tds[index]![2]!, /^\d+宫$/); + } + assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the shifted column remains centered"); + } }); test("an equal-house placeholder MC or a house-system fallback is never drawn", async () => { diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx index 61827d1c..79237cb0 100644 --- a/frontend/tests/chart-moon-transit.test.tsx +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -127,6 +127,21 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode assert.match(markup, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/); for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); assert.ok(markup.includes(CHART_VIEW_COPY.transitNote)); + assert.match(markup, /
/); + assert.match(markup, /
岁差<\/dt>/); + assert.match(markup, /
交点<\/dt>/); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎([\s\S]*?)<\/tbody>/)![1]!; + const rows = [...body.matchAll(/([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 9); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/]*)>([\s\S]*?)<\/td>/g)]; + for (const index of [2, 3]) { + assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment"); + assert.match(tds[index]![2]!, /^\d+宫$/); + } + assert.match(tds[1]![1]!, /chart-page-planet-mid/, "direction remains centered"); + } }); test("the transit tab waits, then fails with the layer copy, and is never requested for D1", async () => { diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index 8cbcadb0..2f967c90 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import React, { useLayoutEffect } from "react"; import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { ChartParamTable } from "../src/components/chart-page/chart-param-table.tsx"; import { useChartPage } from "../src/hooks/use-chart-page.ts"; import { bindCurrentSubjectAccount, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; @@ -163,6 +166,58 @@ test("the transit layer merges beside varga and survives a late warm D1 refresh" assert.ok(view.vedic.vargas.some((x) => x.id === "D60")); })); +test("old capability-less module cache stays hidden through direct reads and layer merges until refreshed", async () => harness(async (h, state) => { + const initial = await packet(); + const currentVargas = await packet("varga"); + const western = await packet("western"); + const legacy = structuredClone(currentVargas.body) as ChartViewOk; + Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" }); + for (const varga of legacy.vedic.vargas) { + delete varga.supportsDignity; + delete varga.supportsVargottama; + } + pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v1" }); + writeChartPage({ kind: "view", view: legacy }); + const refresh = deferred(); + globalThis.fetch = async (url) => String(url).includes("layers=western") ? response(western) + : String(url).includes("layers=varga") ? response(currentVargas) : refresh.promise; + const assertLegacyHidden = () => { + const view = state().view as ChartViewOk; + const d9 = view.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(d9.supportsDignity, undefined); + assert.equal(d9.supportsVargottama, undefined); + assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|>不适用 state().requestLayer("western")); + assert.equal((state().view as ChartViewOk).western.status, "ok"); + assertLegacyHidden(); // Unrelated layer merge retains the old varga object. + await h.update(() => refresh.resolve(response(initial))); + assertLegacyHidden(); // Late D1 refresh retains already loaded vargas too. + await h.update(() => state().requestLayer("varga")); + const updated = state().view as ChartViewOk; + const d9 = updated.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(d9.supportsDignity, true); + assert.equal(d9.supportsVargottama, true); + assert.match(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<[\s\S]*>同位 harness(async (h, state) => { + const initial = await packet(); + const invalid = await packet("varga"); + const body = structuredClone(invalid.body) as ChartViewOk; + const d9 = body.vedic.vargas.find((item) => item.id === "D9")!; + Object.assign(d9, { supportsVargottama: "true" }); + globalThis.fetch = async (url) => String(url).includes("layers=") ? response({ ...invalid, body }) : response(initial); + await h.render(null); + await h.update(() => state().requestLayer("varga")); + assert.equal(state().layerFailures.get("varga"), "response_schema_failure"); + assert.deepEqual(state().view, initial.body); +})); + test("HTTP 429 without a valid body remains engine_busy", async () => harness(async (h, state) => { const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response("busy", { status: 429 }) : response(initial); await h.render(null); await h.update(() => state().requestLayer("western")); diff --git a/frontend/tests/chart-page-mobile-width-20260929.test.ts b/frontend/tests/chart-page-mobile-width-20260929.test.ts index 526b8968..984c9857 100644 --- a/frontend/tests/chart-page-mobile-width-20260929.test.ts +++ b/frontend/tests/chart-page-mobile-width-20260929.test.ts @@ -20,6 +20,33 @@ test("the secondary page's single column may shrink below its content (BUG-1083) assert.match(page, /grid-template-columns:\s*minmax\(0,\s*1fr\)/); }); +test("chart drawing limits are desktop-only and exclude the padded Vedic stage", () => { + const desktopStart = globalStyles.indexOf("@media (min-width: 1024px) {\n .chart-page-vedic-layout,"); + assert.ok(desktopStart >= 0); + const desktopEnd = globalStyles.indexOf("\n}", desktopStart); + const desktop = globalStyles.slice(desktopStart, desktopEnd + 2); + const outsideDesktop = globalStyles.slice(0, desktopStart) + globalStyles.slice(desktopEnd + 2); + assert.match(cssDeclarations(".chart-page-vedic-plot", desktop), /max-width:\s*440px/); + assert.match(cssDeclarations(".chart-page-western-wheel", desktop), /max-width:\s*480px/); + assert.doesNotMatch(cssDeclarations(".chart-page-vedic-plot", outsideDesktop), /max-width/); + assert.doesNotMatch(cssDeclarations(".chart-page-vedic-stage", globalStyles), /max-width/); + assert.match(cssDeclarations(".chart-page-western-wheel", outsideDesktop), /max-width:\s*520px/); + assert.match(cssDeclarations(".chart-page-waiting-plot", globalStyles), /width:\s*100%/); + const skeleton = readFileSync(new URL("../src/components/chart-page/chart-page-skeleton.tsx", import.meta.url), "utf8"); + assert.match(skeleton, /chart-page-western-layout/); + assert.match(skeleton, /chart-page-vedic-layout/); + assert.match(skeleton, /chart-page-vedic-plot/); +}); + +test("the Vedic waiting slot reserves the result legend band without changing the shared SVG", () => { + assert.match(cssDeclarations(".chart-page-skeleton.chart-page-vedic-plot", globalStyles), /aspect-ratio:\s*400\s*\/\s*452/); +}); + +test("only the house column in frozen natal and Moon tables overrides centering", () => { + assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/); + assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/); +}); + test("wide chart-page content still scrolls inside its own box instead of widening the page", () => { assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /overflow-x:\s*auto/); assert.match(cssDeclarations(".chart-page-type-strip", globalStyles), /overflow-x:\s*auto/); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index a2b9e62b..3f09cabe 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -86,6 +86,21 @@ function withSidebarProvider(element: React.ReactElement) { return renderToStaticMarkup(React.createElement(SidebarProvider, null, element)); } +test("all chart types omit the engine display without removing calculation parameters", async () => { + const view = await okView(golden.western); + for (const varga of view.vedic.vargas) { + const markup = renderToStaticMarkup( {}} />); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎岁差交点 {}} />); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎), /Swiss Ephemeris|>引擎 { const view = await okView(); for (const tab of CHART_VIEW_TABS) { @@ -404,7 +419,10 @@ test("the western tab lists every planet and filters aspects for the selected bo assert.match(markup, /太阳 /); assert.match(markup, /月亮 /); assert.match(markup, /星群 摩羯/); + // 原值:所有宽度均为 520px 上限;新值:桌面 480px、窄屏仍 520px。 + // 原因:TASK-chart-surface-polish 只收紧桌面盘宽,不修改 viewBox 或手机布局。 assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/); + assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*480px/); assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/); assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/); assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/); diff --git a/frontend/tests/chart-planet-glyphs.test.tsx b/frontend/tests/chart-planet-glyphs.test.tsx index 80a83b6d..d27d710f 100644 --- a/frontend/tests/chart-planet-glyphs.test.tsx +++ b/frontend/tests/chart-planet-glyphs.test.tsx @@ -45,8 +45,11 @@ test("a house of display text renders symbols, and a retrograde body gets a bar assert.match(markup, /class="personal-report-chart-retrograde is-node"/, "罗逆 gets a bar"); assert.doesNotMatch(markup, /class="personal-report-chart-retrograde is-mercury"/, "水 is direct"); assert.doesNotMatch(markup, /°/, "degrees are gone from the chart"); - // 「逆」survives only in the legend line 横线=逆行. - assert.deepEqual(markup.match(/逆/g), ["逆"]); + // 原值:全 markup 的「逆」只有一处;新值:只计 SVG 可见 text,另测说明节点。 + // 原因:页面交互说明可包含逆行,但盘内不能重新塞回「逆」字标签。 + const visibleText = [...markup.matchAll(/]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join(""); + assert.deepEqual(visibleText.match(/逆/g), ["逆"]); + assert.doesNotMatch(markup, /data-chart-help|tabindex|/, "shared default stays static"); }); test("the rectification board's full Chinese names map to the same symbols", () => { diff --git a/frontend/tests/chart-symbol-help.test.tsx b/frontend/tests/chart-symbol-help.test.tsx new file mode 100644 index 00000000..80e850bb --- /dev/null +++ b/frontend/tests/chart-symbol-help.test.tsx @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; +import { ChartSymbolHelp } from "../src/components/chart-page/chart-symbol-help.tsx"; +import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx"; +import { VedicChartSvg, VedicChartSvgDrawing } from "../src/components/personal-report/vedic-chart-svg.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { reportChartExportSvg } from "../src/lib/report-chart-export.tsx"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +async function view(): Promise<ChartViewOk> { + const result = await assembleChartView({ + userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", + profile: { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" }, + asOf: "2026-09-15", layers: ["varga", "western"], + postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : path === "/api/varga_full" ? golden.varga_full : golden.western }), + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} +type Harness = ReturnType<typeof createClientLifecycleHarness>; +function wrapper(h: Harness) { return h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!; } +function note(h: Harness) { return h.elements().find((node) => node.getAttribute("role") === "status")!; } +function target(h: Harness, fragment: string) { return h.elements().find((node) => node.getAttribute("data-chart-help")?.includes(fragment))!; } + +// These mount real React components and invoke their host handlers. Geometry, +// native pointer dispatch and touch target dimensions still need browser QA. +test("Vedic help supports hover, focus, touch click, leave, blur and Escape without changing glyphs", async () => { + const data = await view(); + const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart; + const h = createClientLifecycleHarness(); + try { + await h.render(<ChartSymbolHelp resetKey="D1"><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>); + const retro = target(h, "逆行"); + assert.ok(retro, "golden contains a retrograde planet"); + assert.equal(retro.getAttribute("role"), "button"); + assert.equal(retro.childNodes.filter((node) => node.getAttribute("tabindex") !== null).length, 0, "bar and glyph share one focus stop"); + await h.event(wrapper(h), "onPointerOver", { target: retro.childNodes.find((node) => node.tagName === "LINE") ?? retro, pointerType: "mouse" }); + assert.match(note(h).text, /逆行(符号下的横线)/); + await h.event(wrapper(h), "onPointerOut", { target: retro, relatedTarget: null }); + assert.doesNotMatch(note(h).text, /逆行/); + const asc = target(h, "As · 上升"); + await h.event(wrapper(h), "onFocus", { target: asc }); + assert.equal(note(h).text, "As · 上升"); + await h.event(wrapper(h), "onBlur", { target: asc }); + assert.doesNotMatch(note(h).text, /As/); + await h.event(wrapper(h), "onClick", { target: asc, pointerType: "touch" }); + assert.equal(note(h).text, "As · 上升", "touch uses a visible page note, not native title"); + await h.event(wrapper(h), "onKeyDown", { target: asc, key: "Escape" }); + assert.doesNotMatch(note(h).text, /As/); + const sign = target(h, "星座编号"); + await h.event(wrapper(h), "onKeyDown", { target: sign, key: " " }); + assert.match(note(h).text, /座,星座编号,不是宫位编号/); + await h.event(wrapper(h), "onPointerLeave"); + assert.doesNotMatch(note(h).text, /星座编号/); + assert.deepEqual(h.errors, []); + } finally { await h.close(); } +}); + +test("Vedic help resets on chart, person or data identity changes", async () => { + const data = await view(); + const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart; + const h = createClientLifecycleHarness(); + try { + for (const key of ["person-a:D1:v1", "person-a:D9:v1", "person-b:D9:v1", "person-b:D9:v2"]) { + await h.render(<ChartSymbolHelp resetKey={key}><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>); + assert.doesNotMatch(note(h).text, /As/); + await h.event(wrapper(h), "onClick", { target: target(h, "As · 上升") }); + assert.equal(note(h).text, "As · 上升"); + } + } finally { await h.close(); } +}); + +test("crowded Vedic help explains overflow and preserves static report/export boundaries", async () => { + const data = await view(); + const chart = structuredClone(data.vedic.vargas.find((varga) => varga.id === "D1")!.chart); + // Directed crowding mutation of the engine golden, not a made-up response. + const occupants = chart.houses.flatMap((house) => house.occupants); + const occupantGlyphs = chart.houses.flatMap((house) => house.occupantGlyphs ?? []); + chart.houses.forEach((house, index) => { + house.occupants = index === 0 ? occupants : []; + house.occupantGlyphs = index === 0 ? occupantGlyphs : []; + }); + const interactive = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive />); + assert.match(interactive, /这一宫还有 \d+ 个标记未展开/); + assert.match(interactive, /role="group"/); + assert.match(interactive, /<title>[^<]*逆行/); + for (const title of interactive.matchAll(/<title>(.*?)<\/title>/g)) { + assert.doesNotMatch(title[1]!, /°|′/, "help does not reverse-engineer precise degrees from display strings"); + } + const skeleton = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive skeleton />); + assert.doesNotMatch(skeleton, /data-chart-help|tabindex|role="button"|<title>/); + const visibleText = [...interactive.matchAll(/<text\b[^>]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join(""); + assert.deepEqual(visibleText.match(/逆/g), ["逆"]); + const staticMarkup = renderToStaticMarkup(VedicChartSvgDrawing({ chart, ariaLabel: "测试盘", chartId: "static" })); + const exported = reportChartExportSvg(chart, "测试盘"); + for (const markup of [staticMarkup, exported]) { + assert.doesNotMatch(markup, /data-chart-help|tabindex|role="button"|<title>|<script|\son\w+=|height="auto"/); + assert.match(markup, /横线=逆行/); + } + assert.match(exported, /height="452"/); +}); + +test("Western visible caption and hit target explain the same nearest planet as selection; Escape only closes help", async () => { + const data = await view(); + assert.equal(data.western.status, "ok"); + if (data.western.status !== "ok") return; + const planets = data.western.planets; + const h = createClientLifecycleHarness(); + try { + await h.render(<ChartWesternTab view={data} />); + const hosts = h.elements(); + const lastHit = Math.max(...hosts.map((node, index) => node.getAttribute("class") === "chart-page-western-hit" ? index : -1)); + for (const className of ["chart-page-western-sign-label", "chart-page-western-house-label", "chart-page-western-angle"]) { + assert.ok(hosts.findIndex((node) => node.getAttribute("class") === className) > lastHit, `${className} stays above transparent hit circles`); + } + const caption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[0]!.id)!; + const otherCaption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[1]!.id)!; + const text = otherCaption.childNodes.find((node) => node.tagName === "TEXT")!; + const point = { x: Number(text.props.x), y: Number(text.props.y) }; + const ownerSVGElement = { getScreenCTM: () => ({ inverse: () => ({}) }), createSVGPoint: () => ({ x: 0, y: 0, matrixTransform: () => point }) }; + const event = { currentTarget: { ownerSVGElement }, clientX: point.x, clientY: point.y, pointerType: "mouse" }; + await h.event(caption, "onPointerMove", event); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + await h.event(caption, "onClick", { ...event, pointerType: "touch" }); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + const selected = () => h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-pressed") === "true")!; + assert.match(selected().getAttribute("aria-label")!, new RegExp(planets[1]!.label)); + await h.event(wrapper(h), "onKeyDown", { key: "Escape" }); + assert.doesNotMatch(note(h).text, new RegExp(planets[1]!.label)); + assert.ok(selected(), "Escape does not clear the aspect filter"); + await h.event(caption, "onClick", event); + assert.equal(selected(), undefined, "a second click still cancels selection"); + const hit = h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-label")?.startsWith(planets[0]!.label))!; + await h.event(hit, "onPointerOver", event); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + await h.event(hit, "onFocus"); + assert.match(note(h).text, new RegExp(planets[0]!.label)); + await h.event(hit, "onKeyDown", { key: "Enter" }); + assert.ok(selected()); + await h.event(hit, "onKeyDown", { key: " " }); + assert.equal(selected(), undefined); + await h.event(hit, "onPointerLeave"); + assert.doesNotMatch(note(h).text, new RegExp(planets[0]!.label)); + const asc = target(h, "ASC · 上升"); + await h.event(wrapper(h), "onClick", { target: asc }); + assert.equal(note(h).text, "ASC · 上升"); + await h.render(<ChartWesternTab view={{ ...data, subjectId: "synthetic-other" }} />); + assert.doesNotMatch(note(h).text, /ASC/); + assert.deepEqual(h.errors, []); + } finally { await h.close(); } +}); diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx index 3881230c..0f8b1294 100644 --- a/frontend/tests/chart-varga-table.test.tsx +++ b/frontend/tests/chart-varga-table.test.tsx @@ -4,9 +4,10 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; -import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { ChartVedicTab, VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; -import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { chartViewOkSchema, chartViewVargaSchema, type ChartViewOk, type ChartViewVarga } from "../src/lib/chart-view-contract.ts"; +import { buildChartView } from "../src/lib/chart-view-mapper.ts"; Object.assign(globalThis, { React }); @@ -117,9 +118,132 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co assert.doesNotMatch(header, /星宿|宿主|pada/); assert.match(row(markup, "土星"), />自宫<[\s\S]*>同位</); assert.match(row(markup, "太阳"), />落陷</); + assert.match(row(markup, "上升"), />不适用</, "the ascendant has no sign dignity"); // Every divisional chart the engine returned has its own rows, D1 has none. for (const varga of view.vedic.vargas) { if (varga.id === "D1") assert.equal(varga.rows, undefined); else assert.equal(varga.rows?.length, 9, varga.id); } }); + +function tableMarkup(varga: ChartViewVarga): string { + return renderToStaticMarkup(React.createElement(VargaTable, { varga })); +} + +function assertColumns(markup: string, dignity: boolean, vargottama: boolean) { + const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? ""; + assert.equal(header.includes(">庙旺<"), dignity); + assert.equal(header.includes(">同位<"), vargottama); + const count = 4 + Number(dignity) + Number(vargottama); + assert.equal((header.match(/<th /g) ?? []).length, count); + const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? ""; + const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 10, "the ascendant and all nine planets remain"); + for (const [, cells] of rows) assert.equal((cells!.match(/<(?:th|td)\b/g) ?? []).length, count); +} + +test("server capabilities retain dignity for every Dn and vargottama only for D9", async () => { + const view = await vargaView(); + for (const varga of view.vedic.vargas) { + if (varga.id === "D1") { + assert.equal(varga.supportsDignity, undefined); + assert.equal(varga.supportsVargottama, undefined); + continue; + } + assert.equal(varga.supportsDignity, true, varga.id); + assert.equal(varga.supportsVargottama, varga.id === "D9", varga.id); + assertColumns(tableMarkup(varga), true, varga.id === "D9"); + } +}); + +test("capability columns are independent and absent declarations hide whole columns", async () => { + const view = await vargaView(); + const source = view.vedic.vargas.find((varga) => varga.id === "D9")!; + for (const dignity of [undefined, false, true]) { + for (const vargottama of [undefined, false, true]) { + const copy = structuredClone(source); + delete copy.supportsDignity; + delete copy.supportsVargottama; + if (dignity !== undefined) copy.supportsDignity = dignity; + if (vargottama !== undefined) copy.supportsVargottama = vargottama; + assertColumns(tableMarkup(chartViewVargaSchema.parse(copy)), dignity === true, vargottama === true); + } + } +}); + +test("valid false and null results never turn supported columns off", async () => { + const view = await vargaView(); + const copy = structuredClone(view.vedic.vargas.find((varga) => varga.id === "D9")!); + for (const item of copy.rows!) { + item.dignity = null; + item.vargottama = false; + } + copy.ascendant!.vargottama = false; + const markup = tableMarkup(chartViewVargaSchema.parse(copy)); + assertColumns(markup, true, true); + assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|>入旺<|>自宫<|>落陷</); + assert.match(row(markup, "上升"), />不适用</); +}); + +test("capability schema rejects wrong types and direct cache rendering requires strict true", async () => { + const view = await vargaView(); + const source = view.vedic.vargas.find((varga) => varga.id === "D9")!; + for (const field of ["supportsDignity", "supportsVargottama"] as const) { + for (const value of [null, "true", "false", 1, 0, [], {}]) { + const copy = structuredClone(source); + Object.assign(copy, { [field]: value }); + const parsed = chartViewVargaSchema.safeParse(copy); + assert.equal(parsed.success, false, `${field} must not coerce ${JSON.stringify(value)}`); + if (!parsed.success) assert.ok(parsed.error.issues.some((issue) => issue.path[0] === field && issue.code === "invalid_type")); + assertColumns(tableMarkup(copy), field !== "supportsDignity", field !== "supportsVargottama"); + } + } +}); + +test("missing or invalid source positions are not manufactured as negative same-sign results", () => { + const chart = structuredClone(golden.chart); + const varga = structuredClone(golden.varga_full); + const d9 = varga.result.D9_Navamsa!; + delete d9.planets.Sun!.sign; + d9.planets.Moon!.sign = "not-a-sign"; + const view = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" }); + const projected = view.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(projected.supportsDignity, true); + assert.equal(projected.supportsVargottama, true); + assert.equal(projected.rows!.some((item) => item.name === "Sun" || item.name === "Moon"), false); + // Destroy only the source positions on a golden copy, rather than making a + // response shape. With no valid result source no capability is advertised. + for (const planet of Object.values(d9.planets)) delete planet.sign; + delete (chart.ascendant as Record<string, unknown>).sign; + const empty = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" }); + const emptyD9 = empty.vedic.vargas.find((item) => item.id === "D9")!; + assert.deepEqual(emptyD9.rows, []); + assert.equal(emptyD9.ascendant, undefined); + assert.equal(emptyD9.supportsDignity, false); + assert.equal(emptyD9.supportsVargottama, false); +}); + +test("view projection omits engine branding but old profile fields still parse and raw metadata stays intact", async () => { + const before = structuredClone(golden); + const view = await vargaView(); + assert.equal("engineName" in view.profile, false); + const legacy = structuredClone(view); + Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" }); + assert.equal("engineName" in chartViewOkSchema.parse(legacy).profile, false); + assert.deepEqual(golden, before, "the mapper never strips or changes the raw engine packet"); +}); + +test("divisional house cells do not override their header's left alignment", async () => { + const view = await vargaView(); + for (const varga of view.vedic.vargas.filter((item) => item.id !== "D1")) { + const markup = tableMarkup(varga); + const rows = [...markup.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].slice(1); + assert.equal(rows.length, 10); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)]; + assert.equal(tds[1]![1], "", "house cells inherit the same alignment and padding as the header"); + assert.match(tds[1]![2]!, /^\d+宫$/); + assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the dignity column's deliberate centering remains"); + } + } +}); diff --git a/tests/test_chart_page_vedic_tables_contract.py b/tests/test_chart_page_vedic_tables_contract.py index a3473f2d..e49633c0 100644 --- a/tests/test_chart_page_vedic_tables_contract.py +++ b/tests/test_chart_page_vedic_tables_contract.py @@ -9,14 +9,19 @@ uses and to `divisional_charts_extended.VargaType`. from __future__ import annotations import json +import shutil +import subprocess import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) import divisional_charts_extended # noqa: E402 import jyotish_engine # noqa: E402 +from consultation_native_layers import build_d9_summary # noqa: E402 TABLES = json.loads((ROOT / "frontend/src/lib/vedic-chart-tables.json").read_text(encoding="utf-8")) PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] @@ -56,3 +61,73 @@ def test_varga_meanings_equal_engine_varga_types() -> None: assert TABLES["vargaMeaning"] == engine for division in ("40", "45", "60"): assert division in TABLES["vargaMeaning"] + + +@pytest.fixture(scope="module") +def d9_projection() -> tuple[dict, dict]: + """Run the real server mapper, not a Python imitation of its comparison. + + The checked-in golden is a fictional chart, Raman ayanamsa / mean node. + No ephemeris is recomputed under a different process-global ayanamsa. + """ + frontend = ROOT / "frontend" + node = shutil.which("node") + tsx = frontend / "node_modules/tsx/dist/cli.mjs" + if not node or not tsx.is_file(): + pytest.skip("D9 mapper/native comparison requires Node and frontend tsx dependencies") + golden = json.loads((frontend / "tests/fixtures/chart-view-golden.json").read_text(encoding="utf-8")) + assert golden["chart"]["birth"]["ayanamsa_name"] == "raman" + assert golden["chart"]["birth"]["node_mode"] == "mean" + script = """ + import { readFileSync } from 'node:fs'; + import { buildChartView } from './src/lib/chart-view-mapper.ts'; + const golden = JSON.parse(readFileSync('./tests/fixtures/chart-view-golden.json', 'utf8')); + const birth = golden.chart.birth; + const view = buildChartView({ + chart: golden.chart, varga: golden.varga_full, chara: null, western: null, + profile: { + name: 'Synthetic', date: birth.date, time: birth.time, + latitude: birth.lat, longitude: birth.lon, timezoneOffset: 8, + ayanamsa: birth.ayanamsa_name, birthTimeStatus: 'confirmed', + }, + asOf: '2026-09-15', + }); + process.stdout.write(JSON.stringify(view.vedic.vargas.find(varga => varga.id === 'D9'))); + """ + completed = subprocess.run( + [node, str(tsx), "--eval", script], + cwd=frontend, text=True, encoding="utf-8", capture_output=True, timeout=60, check=False, + ) + assert completed.returncode == 0, completed.stderr + return golden, json.loads(completed.stdout) + + +def test_d9_projected_planets_match_native_vargottama(d9_projection: tuple[dict, dict]) -> None: + golden, projected = d9_projection + natal = golden["chart"]["planets"] + d9 = golden["varga_full"]["result"]["D9_Navamsa"] + native = jyotish_engine._calc_vargottama(natal, {"D9_Navamsa": d9["planets"]}) + assert projected["supportsDignity"] is True + assert projected["supportsVargottama"] is True + assert len(projected["rows"]) == len(PLANETS) + results = set() + for row in projected["rows"]: + expected = native[row["name"]] + assert row["sign"] == expected["d9_sign"] + assert row["natalSign"] == expected["d1_sign"] + assert row["vargottama"] is expected["is_vargottama"] + results.add(row["vargottama"]) + assert results == {True, False}, "golden must exercise both valid outcomes" + + +def test_d9_projected_ascendant_matches_native_summary(d9_projection: tuple[dict, dict]) -> None: + # _calc_vargottama handles planets, NOT the ascendant; use the native lagna + # summary path for this separate contract instead of attributing it there. + golden, projected = d9_projection + native = build_d9_summary( + jyotish_engine, golden["chart"], {"varga_full": golden["varga_full"]["result"]}, + ) + assert native["status"] == "executed" + assert projected["ascendant"]["sign"] == native["lagna"] + assert projected["ascendant"]["natalSign"] == golden["chart"]["ascendant"]["sign"] + assert projected["ascendant"]["vargottama"] is native["lagna_vargottama"]