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:
Jesse_Chen
2026-09-09 18:44:14 +08:00
co-authored by Cursor
parent 584a56bd81
commit 87daffe251
10 changed files with 449 additions and 66 deletions
+1 -1
View File
@@ -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 “旧版本报告,请重新生成”.
- **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.
- **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.
- **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.
+17 -36
View File
@@ -3832,31 +3832,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-title-sm);
font-weight: 500;
}
@media (min-width: 720px) {
/* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */
.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) {
float: left;
width: 50%;
box-sizing: border-box;
padding-inline: var(--space-3);
margin-top: var(--space-5);
margin-bottom: 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;
}
.personal-report-md-article .personal-report-chart-grid {
margin: var(--space-5) 0 var(--space-6);
}
.personal-report-md-article .personal-report-chart-card h4 {
margin: 0;
font-size: var(--type-title-sm);
font-weight: 500;
}
.personal-report-chart-card .personal-report-chart-figure {
max-width: none;
margin: var(--space-3) 0 0;
}
@media (max-width: 860px) {
.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-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-md-article .personal-report-chart-figure {
float: none;
.personal-report-md-article .personal-report-chart-card .personal-report-chart-figure {
max-width: none;
width: auto;
max-width: 360px;
margin-left: auto;
margin-right: auto;
margin: 3mm 0 0;
padding-top: 0;
break-inside: avoid;
}
.personal-report-md-article h4.personal-report-chart-heading {
float: none;
width: auto;
margin-left: auto;
margin-right: auto;
max-width: 360px;
.personal-report-md-article .personal-report-chart-card h4 {
max-width: none;
padding-inline: 0;
}
.personal-report-chart-card { break-inside: avoid; }
.personal-report-chart-grid,
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
.personal-report-chart-grid.is-single { max-width: 118mm; }
@@ -1,6 +1,6 @@
"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 remarkGfm from "remark-gfm";
@@ -10,6 +10,7 @@ import {
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
import {
buildLongformOutline,
@@ -90,6 +91,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
<ReactMarkdown
components={markdownComponents(headings)}
disallowedElements={["script", "iframe", "object", "embed", "img"]}
rehypePlugins={[reportChartGrid]}
remarkPlugins={[remarkGfm]}
skipHtml
unwrapDisallowed
@@ -100,7 +102,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
);
}
function LazyMarkdownSection({
const LazyMarkdownSection = memo(function LazyMarkdownSection({
section,
}: {
section: LongformSection;
@@ -120,21 +122,24 @@ function LazyMarkdownSection({
observer.observe(ref.current);
return () => observer.disconnect();
}, [visible]);
const content = useMemo(
() => (visible ? renderMarkdown(section.markdown, section.headings) : (
<h2 id={section.id}>{section.title}</h2>
)),
[visible, section],
);
return (
<section ref={ref} className="personal-report-md-section">
{visible ? renderMarkdown(section.markdown, section.headings) : (
<h2 id={section.id}>{section.title}</h2>
)}
{content}
</section>
);
}
});
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? "");
function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
useEffect(() => {
if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return;
if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
@@ -142,29 +147,41 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const id = visible[0]?.target.id;
if (id) setActiveId(id);
}, { 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);
if (node) observer.observe(node);
}
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 (
<div className="personal-report-md-layout">
<nav aria-label="报告目录" className="personal-report-toc">
<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>
<ReportToc headings={outline.headings} />
<article className="personal-report-document personal-report-md-article">
{outline.leadMarkdown ? (
{lead ? (
<section className="personal-report-md-lead">
{renderMarkdown(outline.leadMarkdown, outline.headings)}
{lead}
</section>
) : null}
{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 { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
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 = [
"#### D1 — Rashi Chart(本命盘)",
"",
@@ -34,6 +38,27 @@ const MARKDOWN = [
"```",
].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", () => {
const markup = renderToStaticMarkup(
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.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`);
});
});