fix(report): group chart cards in the existing grid and stop remounting on scroll (BUG-616, BUG-617)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
|
## 2026-09-09 — 长报告星盘改成两栏卡片,滚动时不再整页重建
|
||||||
|
|
||||||
|
个人长报告里的本命盘、婚盘、月亮盘和 Vargas I 不再叠在同一块:每张盘和它的标题进一张卡片,连续的卡片走原来的两栏网格;窄屏和打印仍是单栏。滚动目录高亮时,正文里的图不会被拆掉重画。导出的 `.md` 仍带引擎 SVG、不含网页用的 JSON 围栏。Skill 版本不变。
|
||||||
|
|
||||||
|
|
||||||
## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣
|
## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣
|
||||||
|
|
||||||
从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。
|
从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。
|
||||||
|
|||||||
+33
-1
@@ -9426,7 +9426,7 @@
|
|||||||
- 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。
|
- 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。
|
||||||
- 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 `<svg>`,度数/星座/逆行与 core_chart 一致,且该文件在 `CORE_PYTEST_TARGETS`);`frontend/tests/report-chart-block.test.ts`、`frontend/tests/personal-report-markdown-view.test.tsx`(组件 SVG 有 `role="img"`,不含引擎 `viewBox="0 0 420 480"`,坏块显示「图盘数据无效」且无 `<script`)。全量 `npm test` 2975 tests / 2971 pass / 4 fail,失败 4 条均为 Docker Postgres fixture,与图盘无关。`tsc --noEmit` 0 错;`next build --webpack` 后 `/` 仍为 Static。
|
- 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 `<svg>`,度数/星座/逆行与 core_chart 一致,且该文件在 `CORE_PYTEST_TARGETS`);`frontend/tests/report-chart-block.test.ts`、`frontend/tests/personal-report-markdown-view.test.tsx`(组件 SVG 有 `role="img"`,不含引擎 `viewBox="0 0 420 480"`,坏块显示「图盘数据无效」且无 `<script`)。全量 `npm test` 2975 tests / 2971 pass / 4 fail,失败 4 条均为 Docker Postgres fixture,与图盘无关。`tsc --noEmit` 0 错;`next build --webpack` 后 `/` 仍为 Static。
|
||||||
- 防复发:Markdown 阅读页继续 `skipHtml` / 禁止 `rehype-raw`;图盘文本只来自白名单映射。上述测试锁住围栏块数与渲染通路。
|
- 防复发:Markdown 阅读页继续 `skipHtml` / 禁止 `rehype-raw`;图盘文本只来自白名单映射。上述测试锁住围栏块数与渲染通路。
|
||||||
- 相关记录:`TASK-report-md-page-20260906.md`、`TASK-report-chart-render-20260909.md`
|
- 相关记录:`TASK-report-md-page-20260906.md`、`TASK-report-chart-render-20260909.md`、BUG-616、BUG-617
|
||||||
- 复发自:无(09-06 直渲回归,不是旧 BUG 编号复发)
|
- 复发自:无(09-06 直渲回归,不是旧 BUG 编号复发)
|
||||||
- 修复版本:待发布
|
- 修复版本:待发布
|
||||||
|
|
||||||
@@ -9525,3 +9525,35 @@
|
|||||||
- 相关记录:BUG-612(同一次事故的空回答)、`TASK-consultation-daily-empty-answer-20260909.md`
|
- 相关记录:BUG-612(同一次事故的空回答)、`TASK-consultation-daily-empty-answer-20260909.md`
|
||||||
- 复发自:无
|
- 复发自:无
|
||||||
- 修复版本:待发布
|
- 修复版本:待发布
|
||||||
|
|
||||||
|
## BUG-616 | 报告页 22 张北印盘叠在同一位置
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-09-09
|
||||||
|
- 最近更新:2026-09-09
|
||||||
|
- 影响面:`/personal-report` Markdown 阅读页、`personal-report-markdown-view.tsx`、`globals.css`、`report-chart-grid-rehype.ts`
|
||||||
|
- 用户现象:「Birth Chart / Vargas」里 D1 / D9 / Moon 三张盘叠在同一块;Vargas I 的 19 个 `####` 标题挤在左上角,19 张盘全部叠在一起,「Shodashvarga 星座总表」和后面的表也压在盘上。
|
||||||
|
- 触发条件:打开含 `jyotish-chart` 围栏的个人长报告详情页,宽于 720px 的桌面视口。
|
||||||
|
- 根因:BUG-607 用浮动 + `margin-left: -50%` 在扁平 Markdown 兄弟节点上凑两栏。负 margin 让每个 figure 的 margin box 宽度为 0,浮动算法认为它不占横向空间,连续多对标题+盘必然重叠。`renderToStaticMarkup` 只看 DOM,看不见布局,所以当时的 golden 全绿。
|
||||||
|
- 修复:删掉 `.personal-report-chart-*` 上的 float / 负 margin。rehype 插件把「`h4`(D… / Moon Chart)+ 可选一段说明 + `pre > code.language-jyotish-chart`」包进 `.personal-report-chart-card`,相邻卡片再包进既有的 `.personal-report-chart-grid`(单张加 `is-single`)。不放开 `skipHtml`,不引入 `rehype-raw`。
|
||||||
|
- 验证:`frontend/tests/personal-report-markdown-view.test.tsx`:3 对连续标题+围栏(第 3 对夹一段 `p`)得到 1 个 grid / 3 张卡,卡内顺序为 `h4` →(`p`)→ `figure`,后面的普通 `h4` 不在卡内;单对带 `is-single`;golden 22 张 `figure` 全在卡内;`globals.css` 中同时含 `personal-report-chart` 与 `float:` / `margin-left: -50%` 的规则为 0。
|
||||||
|
- 防复发:布局合同走 DOM 结构(grid/card 包裹)和 CSS 禁令,不只看「有没有 `<svg>`」。
|
||||||
|
- 相关记录:BUG-607、`TASK-report-chart-layout-fix-20260909.md`
|
||||||
|
- 复发自:无(BUG-607 的两栏浮动方案回归,不是「标题下没图」复发)
|
||||||
|
- 修复版本:待发布
|
||||||
|
|
||||||
|
## BUG-617 | 滚动报告时整篇 Markdown 卸载重建,页面卡顿
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-09-09
|
||||||
|
- 最近更新:2026-09-09
|
||||||
|
- 影响面:`personal-report-markdown-view.tsx` 目录高亮与文章树
|
||||||
|
- 用户现象:长报告页(尤其 Vargas I 19 张盘揭开后)一滚动就明显卡,像整页在闪。
|
||||||
|
- 触发条件:打开含星盘围栏的长报告,滚动使目录高亮从一个 `h2`/`h3` 切到下一个。
|
||||||
|
- 根因:`activeId` 放在 `PersonalReportMarkdownView` 顶层,IntersectionObserver 每次切换都 `setActiveId`。`markdownComponents()` 每渲染新建一套 `h2`/`h3`/`h4`/`pre` 函数;React 按元素 type 引用比较,type 变了就卸载重建整棵子树。09-06 Markdown 直渲时已是这个结构,当时没有 SVG,代价没被感知;22 张盘进来后每次滚过一个标题就销毁再创建全部多边形和 `<text>`。
|
||||||
|
- 修复:目录高亮下沉到 `ReportToc`;`PersonalReportMarkdownView` 不再持有滚动态。lead 与 `LazyMarkdownSection` 的 `renderMarkdown` 结果整树 `useMemo`,`LazyMarkdownSection` 本身 `memo()`。
|
||||||
|
- 验证:源码合同:`renderMarkdown(` 只在 `useMemo` 内调用;`PersonalReportMarkdownView` 函数体没有 `useState`。本仓测试是 `renderToStaticMarkup`,数不了重渲染次数;真机条目写在 `docs/testing/report-chart-render-20260909.md` 第 7 节。
|
||||||
|
- 防复发:文章树不得订阅目录高亮 state。`components` 映射可以每次 `renderMarkdown` 新建(id 去重 cursor 需要),但调用点必须被 `useMemo` 包住。
|
||||||
|
- 相关记录:BUG-616、`TASK-report-md-page-20260906.md`、`TASK-report-chart-layout-fix-20260909.md`
|
||||||
|
- 复发自:无(09-06 直渲结构在星盘进入后才变成可感知卡顿)
|
||||||
|
- 修复版本:待发布
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# PROGRESS · 报告页星盘重叠 + 滚动重建(2026-09-09)
|
||||||
|
|
||||||
|
- 工作树:`.worktrees/report-chart-layout-fix-20260909`
|
||||||
|
- 分支:`codex/report-chart-layout-fix-20260909`(track `origin/staging`)
|
||||||
|
- 基线:`origin/staging` @ `584a56bd`(任务书本身;代码基线 `17b36f3a`)
|
||||||
|
- BUG:**BUG-616**、**BUG-617**
|
||||||
|
- 未改 `.gitea/workflows/**`、`frontend/src/app/page.tsx`、下载剥围栏;不提升 `main`,未 push
|
||||||
|
|
||||||
|
## 做了什么
|
||||||
|
|
||||||
|
| 任务 | 状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 两栏成组 | 完成 | `report-chart-grid-rehype.ts` 把 D… / Moon Chart 标题 + 可选 `p` + `jyotish-chart` 围栏包进 `.personal-report-chart-card`,相邻卡进既有 `.personal-report-chart-grid`;删 float / `margin-left: -50%` |
|
||||||
|
| 2 滚动不重建 | 完成 | `ReportToc` 持有 `activeId` 与 observer;lead / `LazyMarkdownSection` 的 `renderMarkdown` 在 `useMemo` 内;`LazyMarkdownSection` `memo()` |
|
||||||
|
| 3 记录 | 完成 | BUG-616/617、CHANGELOG、DESIGN、走查清单第 7 节、本文件;任务板改「待验收」 |
|
||||||
|
|
||||||
|
没有砍让步项。rehype 两栏一次做成,未退到 D2 单栏。
|
||||||
|
|
||||||
|
## 口径(与任务书一致)
|
||||||
|
|
||||||
|
- 两栏保留,不再用浮动凑。
|
||||||
|
- `skipHtml` / 禁止 `rehype-raw` / 不新增 npm 依赖。
|
||||||
|
- 下载 `.md` 仍剥围栏、保留标题。
|
||||||
|
- 本仓测试无 DOM 运行时,重渲染次数不能自动验;真机两条写在走查清单第 7 节。
|
||||||
|
|
||||||
|
## 组件树(改动前后)
|
||||||
|
|
||||||
|
改前:
|
||||||
|
|
||||||
|
```
|
||||||
|
PersonalReportMarkdownView
|
||||||
|
useState(activeId) + IntersectionObserver ← 目录一变,整树重渲染
|
||||||
|
renderMarkdown(lead) ← 每次新建 components,子树卸载
|
||||||
|
LazyMarkdownSection (无 memo)
|
||||||
|
renderMarkdown(section) ← 同上
|
||||||
|
```
|
||||||
|
|
||||||
|
改后:
|
||||||
|
|
||||||
|
```
|
||||||
|
PersonalReportMarkdownView ← 无 useState,只随 markdown 变
|
||||||
|
ReportToc
|
||||||
|
useState(activeId) + IntersectionObserver
|
||||||
|
TocList × 2
|
||||||
|
article
|
||||||
|
lead = useMemo(renderMarkdown)
|
||||||
|
memo(LazyMarkdownSection)
|
||||||
|
content = useMemo(renderMarkdown | h2 占位)
|
||||||
|
```
|
||||||
|
|
||||||
|
滚动只重绘 `ReportToc`。文章树里的 SVG 保持同一组 DOM 节点(真机第 7 节)。
|
||||||
|
|
||||||
|
## 偏离
|
||||||
|
|
||||||
|
1. **两栏折断点跟既有 document-view grid,是 760px 而不是任务书正文里的 860px。** `globals.css` 里 `.personal-report-chart-grid` 的单栏媒体查询一直是 `max-width: 760px`;860px 只折叠 TOC。未改这个既有数字。
|
||||||
|
2. **rehype 插件导出了 `groupReportChartNodes`。** 给同一文件的工厂函数用,测试仍走 `PersonalReportMarkdownView` 的静态标记,没有另开 hast 单测。
|
||||||
|
|
||||||
|
## 测试数字
|
||||||
|
|
||||||
|
| 项 | 本轮实测 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tsc --noEmit` | 0 错 | 通过 |
|
||||||
|
| `npm run lint` | 0 error / 108 warning | warning 数与 BUG-607 收工相同;新文件无 error |
|
||||||
|
| `tsx --test tests/personal-report-markdown-view.test.tsx` | 6 passed | 原 1 条 + 本单 5 条 |
|
||||||
|
| `globals.css` gzip | 开工 `35791` B → 工作树 `35612` B | −179 B / −0.50%,在 ±2% 内 |
|
||||||
|
| `next build --webpack` `/` | `○ Static` | 交叉 `node_modules` 符号链接,与 BUG-607 相同改用 webpack |
|
||||||
|
| 首页 CSS gzip(构建产物) | 最大块 `a083d2bd2a5e75cb.css` = 36,083 B | 相对 BUG-607 的 35,779 B 为 +0.85%,在 ±2% 内 |
|
||||||
|
| `npm test` tests | 3008 | 高于 BUG-607 的 2975(staging 后来加了测试,本单 +5) |
|
||||||
|
| `npm test` pass | 2997 | |
|
||||||
|
| `npm test` fail | 11 | 全是 Docker Postgres fixture / 迁移 / 超时 / onboarding deadline flake,与图盘无关 |
|
||||||
|
| `npm test` skipped | 0 | Docker 可用 |
|
||||||
|
|
||||||
|
11 条失败(均非本轮图盘代码):
|
||||||
|
|
||||||
|
1. `admin-database.test.ts` — `database migration failed`
|
||||||
|
2. `database-admin-account-reset.test.ts` — 同上
|
||||||
|
3. `database-admin-owner-recovery.test.ts` — 同上
|
||||||
|
4. `database-billing-audit.test.ts` — 同上(billing order adjustments…)
|
||||||
|
5. `database-billing-product-admin.test.ts` — 300s 超时(当时并行跑过 `next build`)
|
||||||
|
6. `database-migrate.test.ts` — 300s 超时(同上)
|
||||||
|
7. `database-self-hosted-identity.test.ts` — Better Auth OTP fixture
|
||||||
|
8. `onboarding-route.test.ts` — `slow Agent generation` 把 `deadline` 当成了失败(flake)
|
||||||
|
9–11. `rectification-v9-database.test.ts` 三条 — `database migration failed`
|
||||||
|
|
||||||
|
无 Docker 基线 28 条本轮未复跑(Docker 可用)。图盘相关 6 条 markdown-view + 既有 `report-chart-block.test.ts` 全绿。
|
||||||
|
|
||||||
|
## 没做的
|
||||||
|
|
||||||
|
- **没有在浏览器里打开过一份真实 `/reports/<id>`。** 无登录态。走查清单第 4 节和第 7 节待真机。
|
||||||
|
- 未 push。代码进 staging 会走 `Staging Backend Quality Gate`。
|
||||||
|
- 收工时 `origin/staging` 已含 BUG-614~615、BUG-618~620;合并写在本提交之后。
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
| `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 待验收 | `b466a6fc`(BUG-574) |
|
| `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 待验收 | `b466a6fc`(BUG-574) |
|
||||||
| — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6`、`5565b632`(BUG-601) |
|
| — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6`、`5565b632`(BUG-601) |
|
||||||
| `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `<svg>` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`(BUG-607) |
|
| `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `<svg>` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`(BUG-607) |
|
||||||
| `TASK-report-chart-layout-fix-20260909.md` | — | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待领取 | 分支 `codex/report-chart-layout-fix-20260909`(BUG-616/617) |
|
| `TASK-report-chart-layout-fix-20260909.md` | `PROGRESS-report-chart-layout-fix-20260909.md` | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待验收 | `codex/report-chart-layout-fix-20260909`(BUG-616/617) |
|
||||||
|
|
||||||
### 前端基础与工程
|
### 前端基础与工程
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
## 4. 手机与打印
|
## 4. 手机与打印
|
||||||
|
|
||||||
- [ ] iPhone Safari 竖屏:图单列、居中,不撑出左右滚动(宽表除外)。
|
- [ ] iPhone Safari 竖屏:图单列,不撑出左右滚动(宽表除外),标题在卡内、图在标题下,互不重叠。
|
||||||
- [ ] 桌面 ≥720px:Vargas I 的图是两列。
|
- [ ] 桌面宽于 760px:Vargas I 的图是两列卡片,标题在各自卡顶,没有叠盘、没有标题堆成一列。
|
||||||
- [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。
|
- [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。
|
||||||
|
|
||||||
## 5. 导出 `.md`
|
## 5. 导出 `.md`
|
||||||
@@ -37,4 +37,11 @@
|
|||||||
|
|
||||||
- [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。
|
- [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。
|
||||||
|
|
||||||
未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON,或暗色主题下图看不见,把现象和报告页截图贴回。
|
## 7. 滚动不重建(BUG-617)
|
||||||
|
|
||||||
|
本仓前端测试是 `renderToStaticMarkup`,没有 DOM 运行时,数不了 React 重渲染。下面两条必须在真机(Chrome)上对照一份 Vargas I 已全部揭开的新报告。
|
||||||
|
|
||||||
|
- [ ] Vargas I 全部揭开后连续滚动 10 秒:Chrome Performance 没有 >100ms 的长帧。
|
||||||
|
- [ ] Elements 面板选中 D1 的 `<svg>` 之后滚动、目录高亮切换:选中不丢,该 `<svg>` DOM 节点还是同一个。
|
||||||
|
|
||||||
|
未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON、暗色主题下图看不见、盘重叠,或滚动时 Elements 里 SVG 被替换,把现象和报告页截图贴回。
|
||||||
|
|||||||
+1
-1
@@ -357,7 +357,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a
|
|||||||
- **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
- **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||||
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
|
- **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette.
|
||||||
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
|
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
|
||||||
- **Charts:** each `####` chart heading is followed by a North Indian diamond figure, not engine SVG HTML. Figures are max 360px and centered. From 720px, consecutive heading+figure pairs sit two to a row; print and narrower viewports stay one column. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
|
||||||
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
|
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
|
||||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
|
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them.
|
||||||
- **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px.
|
- **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px.
|
||||||
|
|||||||
@@ -3832,31 +3832,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
font-size: var(--type-title-sm);
|
font-size: var(--type-title-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@media (min-width: 720px) {
|
.personal-report-md-article .personal-report-chart-grid {
|
||||||
/* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */
|
margin: var(--space-5) 0 var(--space-6);
|
||||||
.personal-report-md-lead > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure),
|
}
|
||||||
.personal-report-md-section > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure) {
|
.personal-report-md-article .personal-report-chart-card h4 {
|
||||||
float: left;
|
margin: 0;
|
||||||
width: 50%;
|
font-size: var(--type-title-sm);
|
||||||
box-sizing: border-box;
|
font-weight: 500;
|
||||||
padding-inline: var(--space-3);
|
}
|
||||||
margin-top: var(--space-5);
|
.personal-report-chart-card .personal-report-chart-figure {
|
||||||
margin-bottom: 0;
|
max-width: none;
|
||||||
}
|
margin: var(--space-3) 0 0;
|
||||||
.personal-report-md-lead > h4.personal-report-chart-heading + .personal-report-chart-figure,
|
|
||||||
.personal-report-md-section > h4.personal-report-chart-heading + .personal-report-chart-figure {
|
|
||||||
float: left;
|
|
||||||
width: 50%;
|
|
||||||
margin-left: -50%;
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
padding-top: 2.4em;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
.personal-report-md-lead > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure),
|
|
||||||
.personal-report-md-section > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure) {
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.personal-report-md-layout { grid-template-columns: 1fr; }
|
.personal-report-md-layout { grid-template-columns: 1fr; }
|
||||||
@@ -4418,23 +4404,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
.personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; }
|
.personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; }
|
||||||
.personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; }
|
.personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; }
|
||||||
.personal-report-chart-svg { max-width: none; margin-top: 3mm; }
|
.personal-report-chart-svg { max-width: none; margin-top: 3mm; }
|
||||||
.personal-report-md-article .personal-report-chart-figure {
|
.personal-report-md-article .personal-report-chart-card .personal-report-chart-figure {
|
||||||
float: none;
|
max-width: none;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 360px;
|
margin: 3mm 0 0;
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
.personal-report-md-article h4.personal-report-chart-heading {
|
.personal-report-md-article .personal-report-chart-card h4 {
|
||||||
float: none;
|
max-width: none;
|
||||||
width: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
max-width: 360px;
|
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
.personal-report-chart-card { break-inside: avoid; }
|
||||||
.personal-report-chart-grid,
|
.personal-report-chart-grid,
|
||||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseReportChartBlock,
|
parseReportChartBlock,
|
||||||
toNorthIndianChart,
|
toNorthIndianChart,
|
||||||
} from "@/lib/report-chart-block";
|
} from "@/lib/report-chart-block";
|
||||||
|
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||||
import {
|
import {
|
||||||
buildLongformOutline,
|
buildLongformOutline,
|
||||||
@@ -90,6 +91,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
|||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={markdownComponents(headings)}
|
components={markdownComponents(headings)}
|
||||||
disallowedElements={["script", "iframe", "object", "embed", "img"]}
|
disallowedElements={["script", "iframe", "object", "embed", "img"]}
|
||||||
|
rehypePlugins={[reportChartGrid]}
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
skipHtml
|
skipHtml
|
||||||
unwrapDisallowed
|
unwrapDisallowed
|
||||||
@@ -100,7 +102,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LazyMarkdownSection({
|
const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||||
section,
|
section,
|
||||||
}: {
|
}: {
|
||||||
section: LongformSection;
|
section: LongformSection;
|
||||||
@@ -120,21 +122,24 @@ function LazyMarkdownSection({
|
|||||||
observer.observe(ref.current);
|
observer.observe(ref.current);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
const content = useMemo(
|
||||||
|
() => (visible ? renderMarkdown(section.markdown, section.headings) : (
|
||||||
|
<h2 id={section.id}>{section.title}</h2>
|
||||||
|
)),
|
||||||
|
[visible, section],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<section ref={ref} className="personal-report-md-section">
|
<section ref={ref} className="personal-report-md-section">
|
||||||
{visible ? renderMarkdown(section.markdown, section.headings) : (
|
{content}
|
||||||
<h2 id={section.id}>{section.title}</h2>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
|
||||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
|
||||||
const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return;
|
if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
const visible = entries
|
const visible = entries
|
||||||
.filter((entry) => entry.isIntersecting)
|
.filter((entry) => entry.isIntersecting)
|
||||||
@@ -142,29 +147,41 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
|||||||
const id = visible[0]?.target.id;
|
const id = visible[0]?.target.id;
|
||||||
if (id) setActiveId(id);
|
if (id) setActiveId(id);
|
||||||
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
|
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
|
||||||
for (const item of outline.headings) {
|
for (const item of headings) {
|
||||||
const node = document.getElementById(item.id);
|
const node = document.getElementById(item.id);
|
||||||
if (node) observer.observe(node);
|
if (node) observer.observe(node);
|
||||||
}
|
}
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [outline.headings]);
|
}, [headings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="报告目录" className="personal-report-toc">
|
||||||
|
<details className="personal-report-toc-drawer">
|
||||||
|
<summary>目录</summary>
|
||||||
|
<TocList activeId={activeId} items={headings} />
|
||||||
|
</details>
|
||||||
|
<div className="personal-report-toc-desktop">
|
||||||
|
<p>目录</p>
|
||||||
|
<TocList activeId={activeId} items={headings} />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||||
|
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||||
|
const lead = useMemo(
|
||||||
|
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
|
||||||
|
[outline],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="personal-report-md-layout">
|
<div className="personal-report-md-layout">
|
||||||
<nav aria-label="报告目录" className="personal-report-toc">
|
<ReportToc headings={outline.headings} />
|
||||||
<details className="personal-report-toc-drawer">
|
|
||||||
<summary>目录</summary>
|
|
||||||
<TocList activeId={activeId} items={outline.headings} />
|
|
||||||
</details>
|
|
||||||
<div className="personal-report-toc-desktop">
|
|
||||||
<p>目录</p>
|
|
||||||
<TocList activeId={activeId} items={outline.headings} />
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<article className="personal-report-document personal-report-md-article">
|
<article className="personal-report-document personal-report-md-article">
|
||||||
{outline.leadMarkdown ? (
|
{lead ? (
|
||||||
<section className="personal-report-md-lead">
|
<section className="personal-report-md-lead">
|
||||||
{renderMarkdown(outline.leadMarkdown, outline.headings)}
|
{lead}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{outline.sections.filter((section) => !section.eager).map((section) => (
|
{outline.sections.filter((section) => !section.eager).map((section) => (
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const CHART_HEADING_RE = /^(?:D\d{1,3}\b|Moon Chart)/;
|
||||||
|
|
||||||
|
type HastProperties = {
|
||||||
|
className?: string | string[] | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HastNode = {
|
||||||
|
type: string;
|
||||||
|
tagName?: string;
|
||||||
|
value?: string;
|
||||||
|
properties?: HastProperties;
|
||||||
|
children?: HastNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type HastRoot = {
|
||||||
|
type: "root";
|
||||||
|
children: HastNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function classNames(node: HastNode | undefined): string[] {
|
||||||
|
const value = node?.properties?.className;
|
||||||
|
if (Array.isArray(value)) return value.map(String);
|
||||||
|
if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClass(node: HastNode | undefined, name: string): boolean {
|
||||||
|
return classNames(node).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespace(node: HastNode | undefined): boolean {
|
||||||
|
return Boolean(node && node.type === "text" && !String(node.value ?? "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenText(node: HastNode | undefined): string {
|
||||||
|
if (!node) return "";
|
||||||
|
if (node.type === "text") return String(node.value ?? "");
|
||||||
|
if (!Array.isArray(node.children)) return "";
|
||||||
|
return node.children.map(flattenText).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElement(node: HastNode | undefined, tagName: string): node is HastNode {
|
||||||
|
return Boolean(node && node.type === "element" && node.tagName === tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChartHeading(node: HastNode | undefined): boolean {
|
||||||
|
return isElement(node, "h4") && CHART_HEADING_RE.test(flattenText(node).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChartPre(node: HastNode | undefined): boolean {
|
||||||
|
if (!isElement(node, "pre") || !Array.isArray(node.children)) return false;
|
||||||
|
const code = node.children.find((child) => isElement(child, "code"));
|
||||||
|
return hasClass(code, "language-jyotish-chart") || hasClass(code, "jyotish-chart");
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipWhitespace(nodes: readonly HastNode[], index: number): number {
|
||||||
|
let cursor = index;
|
||||||
|
while (cursor < nodes.length && isWhitespace(nodes[cursor])) cursor += 1;
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function element(tagName: string, className: string[], children: HastNode[]): HastNode {
|
||||||
|
return {
|
||||||
|
type: "element",
|
||||||
|
tagName,
|
||||||
|
properties: { className },
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCard(nodes: readonly HastNode[], index: number): { card: HastNode; next: number } | null {
|
||||||
|
const heading = nodes[index];
|
||||||
|
if (!isChartHeading(heading)) return null;
|
||||||
|
const children: HastNode[] = [heading];
|
||||||
|
let cursor = skipWhitespace(nodes, index + 1);
|
||||||
|
if (isElement(nodes[cursor], "p")) {
|
||||||
|
const afterParagraph = skipWhitespace(nodes, cursor + 1);
|
||||||
|
if (isChartPre(nodes[afterParagraph])) {
|
||||||
|
children.push(nodes[cursor] as HastNode);
|
||||||
|
cursor = afterParagraph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isChartPre(nodes[cursor])) return null;
|
||||||
|
children.push(nodes[cursor] as HastNode);
|
||||||
|
return {
|
||||||
|
card: element("div", ["personal-report-chart-card"], children),
|
||||||
|
next: cursor + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupReportChartNodes(nodes: readonly HastNode[]): HastNode[] {
|
||||||
|
const grouped: HastNode[] = [];
|
||||||
|
let index = 0;
|
||||||
|
while (index < nodes.length) {
|
||||||
|
const first = parseCard(nodes, index);
|
||||||
|
if (!first) {
|
||||||
|
grouped.push(nodes[index] as HastNode);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cards = [first.card];
|
||||||
|
index = first.next;
|
||||||
|
while (index < nodes.length) {
|
||||||
|
const peeked = skipWhitespace(nodes, index);
|
||||||
|
const next = parseCard(nodes, peeked);
|
||||||
|
if (!next) break;
|
||||||
|
cards.push(next.card);
|
||||||
|
index = next.next;
|
||||||
|
}
|
||||||
|
grouped.push(element(
|
||||||
|
"div",
|
||||||
|
cards.length === 1
|
||||||
|
? ["personal-report-chart-grid", "is-single"]
|
||||||
|
: ["personal-report-chart-grid"],
|
||||||
|
cards,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportChartGrid() {
|
||||||
|
return function groupReportCharts(tree: HastRoot) {
|
||||||
|
if (tree?.type !== "root" || !Array.isArray(tree.children)) return;
|
||||||
|
tree.children = groupReportChartNodes(tree.children);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -20,6 +21,9 @@ const SAMPLE_BLOCK: ReportChartBlock = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const D9_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "D9", title: "D9 — Navamsa(婚盘)" };
|
||||||
|
const MOON_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "MOON", title: "Moon Chart(月亮参考盘)" };
|
||||||
|
|
||||||
const MARKDOWN = [
|
const MARKDOWN = [
|
||||||
"#### D1 — Rashi Chart(本命盘)",
|
"#### D1 — Rashi Chart(本命盘)",
|
||||||
"",
|
"",
|
||||||
@@ -34,6 +38,27 @@ const MARKDOWN = [
|
|||||||
"```",
|
"```",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
const GOLDEN = JSON.parse(
|
||||||
|
readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"),
|
||||||
|
) as { blocks: ReportChartBlock[] };
|
||||||
|
|
||||||
|
function fence(block: ReportChartBlock): string {
|
||||||
|
return ["```jyotish-chart", JSON.stringify(block), "```"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function countClass(markup: string, className: string): number {
|
||||||
|
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
return (markup.match(new RegExp(`(?:class|className)="[^"]*\\b${escaped}\\b`, "g")) ?? []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardInnerHtml(markup: string): string[] {
|
||||||
|
const cards: string[] = [];
|
||||||
|
const re = /<div class="personal-report-chart-card">([\s\S]*?)<\/div>/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(markup))) cards.push(match[1] ?? "");
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
|
test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
|
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
|
||||||
@@ -46,3 +71,101 @@ test("markdown view draws the North Indian component and skips engine SVG HTML",
|
|||||||
assert.doesNotMatch(markup, /<script/i);
|
assert.doesNotMatch(markup, /<script/i);
|
||||||
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
|
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("consecutive chart heading+fence pairs become one two-column grid of cards", () => {
|
||||||
|
const markdown = [
|
||||||
|
"#### D1 — Rashi Chart(本命盘)",
|
||||||
|
"",
|
||||||
|
fence(SAMPLE_BLOCK),
|
||||||
|
"",
|
||||||
|
"#### D9 — Navamsa(婚盘)",
|
||||||
|
"",
|
||||||
|
fence(D9_BLOCK),
|
||||||
|
"",
|
||||||
|
"#### Moon Chart(月亮参考盘)",
|
||||||
|
"",
|
||||||
|
"月亮盘画在本命月上,用来对照夜间事件。",
|
||||||
|
"",
|
||||||
|
fence(MOON_BLOCK),
|
||||||
|
"",
|
||||||
|
"#### Shodashvarga 星座总表",
|
||||||
|
"",
|
||||||
|
"| 分盘 | 星座 |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| D1 | Leo |",
|
||||||
|
].join("\n");
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
||||||
|
);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
|
||||||
|
assert.equal(countClass(markup, "is-single"), 0);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-card"), 3);
|
||||||
|
const cards = cardInnerHtml(markup);
|
||||||
|
assert.equal(cards.length, 3);
|
||||||
|
assert.match(cards[0] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
|
||||||
|
assert.match(cards[1] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
|
||||||
|
assert.match(
|
||||||
|
cards[2] ?? "",
|
||||||
|
/^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><p>月亮盘画在本命月上,用来对照夜间事件。<\/p><figure class="personal-report-chart-figure">/,
|
||||||
|
);
|
||||||
|
const withoutCards = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
|
||||||
|
assert.match(withoutCards, /<h4>Shodashvarga 星座总表<\/h4>/);
|
||||||
|
assert.doesNotMatch(withoutCards, /personal-report-chart-card/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a single chart pair is wrapped in an is-single grid", () => {
|
||||||
|
const markdown = ["#### D1 — Rashi Chart(本命盘)", "", fence(SAMPLE_BLOCK)].join("\n");
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
||||||
|
);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
|
||||||
|
assert.equal(countClass(markup, "is-single"), 1);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-card"), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("golden 22 chart figures all sit inside chart cards", () => {
|
||||||
|
const markdown = GOLDEN.blocks.map((block) => [
|
||||||
|
`#### ${block.title}`,
|
||||||
|
"",
|
||||||
|
`<svg viewBox="0 0 420 480"></svg>`,
|
||||||
|
"",
|
||||||
|
fence(block),
|
||||||
|
"",
|
||||||
|
].join("\n")).join("\n");
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
||||||
|
);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-figure"), 22);
|
||||||
|
assert.equal(countClass(markup, "personal-report-chart-card"), 22);
|
||||||
|
const leftover = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
|
||||||
|
assert.doesNotMatch(leftover, /personal-report-chart-figure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chart layout CSS does not float personal-report-chart boxes", () => {
|
||||||
|
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
for (const chunk of css.split("}")) {
|
||||||
|
if (!chunk.includes("personal-report-chart")) continue;
|
||||||
|
assert.doesNotMatch(chunk, /float\s*:/);
|
||||||
|
assert.doesNotMatch(chunk, /margin-left\s*:\s*-50%/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("article markdown trees are memoized and hold no scroll state", () => {
|
||||||
|
const src = readFileSync(
|
||||||
|
new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const viewStart = src.indexOf("export function PersonalReportMarkdownView");
|
||||||
|
const viewEnd = src.indexOf("\nfunction TocList", viewStart);
|
||||||
|
assert.ok(viewStart >= 0 && viewEnd > viewStart);
|
||||||
|
const view = src.slice(viewStart, viewEnd);
|
||||||
|
assert.doesNotMatch(view, /\buseState\b/);
|
||||||
|
const lines = src.split("\n");
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (!line.includes("renderMarkdown(")) return;
|
||||||
|
if (line.includes("function renderMarkdown")) return;
|
||||||
|
const window = lines.slice(Math.max(0, index - 6), index + 1).join("\n");
|
||||||
|
assert.match(window, /useMemo\s*\(/, `renderMarkdown at line ${index + 1} must be inside useMemo`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user