feat(reports): generate card, jump to end, labelled export, TOC levels, table bands, export size; drop 字段 jargon (BUG-1092..1094)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
64aa0ec20c
commit
d85ab9a2d2
+8
-6
@@ -565,7 +565,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
### Personal report centre
|
||||
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits in the 46px header with 「生成完整报告」 as the header's one action; the body starts straight at 「报告记录」 and the **row list** of reports. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet with a file icon, 「生成个人报告」, one line 「创建后即可离开,完成时下方列表会自动更新。」 and the filled 「生成报告」 button — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
|
||||
- **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12.
|
||||
- **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut.
|
||||
@@ -574,7 +574,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow.
|
||||
- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row.
|
||||
- **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut.
|
||||
- **Actions:** “生成完整报告” is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the header already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **Actions:** “生成报告” in the generate card is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the card above already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time.
|
||||
- **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures remain in the open drawer; only success closes it and emits a toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate.
|
||||
- **Accessibility:** generate, refresh, and row actions are 44px. The generating row's chip is a live region; a failed export is an alert.
|
||||
|
||||
@@ -582,10 +582,12 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
- **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
|
||||
- **分块导出对话框 (2026-09-24):** native modal `<dialog>`, bottom-aligned below 860px and centered at ≥860px, app colours only. Desktop is viewport-centered (`inset: 0; margin: auto`) regardless of sidebar state, width `min(720px, 92vw)`, max-height 80vh, with full `--radius-lg` corners. Its flex panel is capped at `calc(80vh - 2px)` to reserve the dialog border; desktop rules follow the narrow-screen dvh upgrade so 70dvh cannot override them. Below 860px the content-column offset, bottom alignment and at most 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts. Beside them the header shows the file size for the whole report and for the selection — the UTF-8 byte length of exactly what would download (`TextEncoder` over `joinExportBlocks`), as 「约 186 KB」 / 「约 1.4 MB」, never a per-character estimate.
|
||||
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`.
|
||||
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the export icon button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
|
||||
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
|
||||
- **到底部 / 回到顶部:** one floating pill (`.report-edge-jump`, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper.
|
||||
- **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer.
|
||||
- **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3.
|
||||
- **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.
|
||||
@@ -593,10 +595,10 @@ one skeleton, none of them carrying the sidebar.
|
||||
- **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:** 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.
|
||||
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
|
||||
- **Width:** 1120px for the reader chrome; the article takes `minmax(0, 1fr)` and the TOC rail `minmax(11rem, 15rem)` to its right. The TOC collapses into a drawer below 860px.
|
||||
- **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, markdown-ready, legacy-unavailable. Waiting uses `InlineSpinner` and one-step copy.
|
||||
- **Accessibility:** the action bar has only 返回报告中心 and a 44×44 Download icon button (`aria-label="导出报告"`, native tooltip「导出」). No raw-appendix or print button, and old `#export` links no longer auto-open a dialog. Browser Ctrl+P keeps the existing print styles and beforeprint/afterprint fact-table expansion/restoration. Generating copy uses `role="status"`. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
- **Accessibility:** the action bar has only 返回报告中心 and an outlined, full-height 「导出」 button with the Download icon (`aria-label="导出报告"`); 2026-09-29 it replaced the 44×44 ghost icon button nobody found. No raw-appendix or print button, and old `#export` links no longer auto-open a dialog. Browser Ctrl+P keeps the existing print styles and beforeprint/afterprint fact-table expansion/restoration. Generating copy uses `role="status"`. Markdown is rendered with `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`.
|
||||
|
||||
### Starter home
|
||||
|
||||
|
||||
@@ -3879,34 +3879,45 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
.report-center-empty {
|
||||
min-height: 220px;
|
||||
/* The page's generate entry: one centred card above the archive (2026-09-29,
|
||||
product sketch). Same sheet as an empty state used to be, so a first visit
|
||||
still reads calm; the button inside is the page's one filled action. */
|
||||
.report-center-create {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8);
|
||||
padding: var(--space-8) var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-empty h3 {
|
||||
.report-center-create h2 {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.report-center-empty p {
|
||||
.report-center-create p {
|
||||
max-width: 460px;
|
||||
margin: 0;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* No second big empty card under the generate card: one quiet line. */
|
||||
.report-center-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-6) 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.report-center-alert {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -4066,7 +4077,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-3); }
|
||||
/* Two levels you can tell apart without the current-item pill (2026-09-29):
|
||||
chapters are ink and semibold with air above each group; sections sit a
|
||||
full step in and one tone lighter. */
|
||||
.personal-report-toc li[data-level="2"] + li[data-level="2"],
|
||||
.personal-report-toc li[data-level="3"] + li[data-level="2"] { margin-top: var(--space-3); }
|
||||
.personal-report-toc li[data-level="2"] > a { color: var(--color-ink); font-weight: 600; }
|
||||
.personal-report-toc li[data-level="3"] { padding-left: var(--space-5); }
|
||||
.personal-report-toc a {
|
||||
display: block;
|
||||
padding: 6px var(--space-2);
|
||||
@@ -4085,6 +4102,39 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.personal-report-toc a:hover { color: var(--color-ink); }
|
||||
/* 「到底部」/「回到顶部」: one floating pill over the reader's bottom-right
|
||||
corner, same look as the chat's 跳到最新. Out of flow, so the paper never
|
||||
reflows when it flips direction. Hidden on paper via .personal-report-screen-only. */
|
||||
.report-edge-jump {
|
||||
position: fixed;
|
||||
right: var(--space-6);
|
||||
bottom: var(--space-6);
|
||||
z-index: 15;
|
||||
}
|
||||
.report-edge-jump__button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.report-edge-jump__button svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.report-edge-jump__button:hover { background: var(--color-canvas-muted); }
|
||||
.report-edge-jump__button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.report-edge-jump { right: var(--space-4); bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.personal-report-toc-drawer { display: none; }
|
||||
.personal-report-toc-desktop p {
|
||||
margin: 0 0 var(--space-2);
|
||||
@@ -4543,6 +4593,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Report tables only, never chat bubbles (2026-09-29): a faint header band and
|
||||
zebra rows so a nine-column row can be followed across. Both are ink mixed
|
||||
into transparency, so they sit on paper and on the dark sheet alike. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th {
|
||||
background: color-mix(in srgb, var(--color-ink) 6%, transparent);
|
||||
}
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--color-ink) 3%, transparent);
|
||||
}
|
||||
.personal-report-key-evidence tbody th { width: 30%; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
|
||||
.personal-report-key-evidence tbody td { color: var(--color-ink-strong); font-size: var(--type-body-sm); }
|
||||
|
||||
@@ -4800,6 +4861,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
|
||||
.personal-report-chart-grid.is-single { max-width: 118mm; }
|
||||
.personal-report-table-wrap { max-width: none !important; overflow: visible !important; }
|
||||
/* Lighter on paper; the print block already forces exact colour. */
|
||||
.personal-report-table-wrap thead th,
|
||||
.personal-report-fact-tables thead th { background: color-mix(in srgb, var(--color-ink) 5%, transparent) !important; }
|
||||
.personal-report-table-wrap tbody tr:nth-child(even),
|
||||
.personal-report-fact-tables tbody tr:nth-child(even) { background: color-mix(in srgb, var(--color-ink) 2.5%, transparent) !important; }
|
||||
|
||||
.personal-report-table-wrap table,
|
||||
.personal-report-key-evidence table { width: 100% !important; min-width: 0 !important; table-layout: fixed !important; }
|
||||
.personal-report-table-wrap th,
|
||||
|
||||
@@ -200,14 +200,12 @@ export function GeneratePersonalReportButton({
|
||||
}
|
||||
|
||||
return (
|
||||
/* Sized for the 46px page header it now lives in. It used to be the
|
||||
default filled Button at full height, which read as a banner CTA
|
||||
floating in the top-right corner rather than the header's own action. */
|
||||
/* The report centre's one filled action, centred in its own card at the
|
||||
top of the page (2026-09-29). It used to be a small outline button in
|
||||
the 46px header, easy to miss beside the page name. */
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={submitting}
|
||||
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { Clock3, FileText, RefreshCw } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -151,14 +151,19 @@ export function PersonalReportCenter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title="我的报告"
|
||||
actions={<GeneratePersonalReportButton onCreated={() => void load()} />}
|
||||
>
|
||||
<SecondaryPageShell title="我的报告">
|
||||
<div className="report-center-body">
|
||||
{/* Generating is the page's first thing, in its own card, and the only
|
||||
entry: the header no longer carries a second button (2026-09-29). */}
|
||||
<section className="report-center-create" aria-labelledby="report-center-create-title">
|
||||
<FileText aria-hidden="true" className="size-7" />
|
||||
<h2 id="report-center-create-title">生成个人报告</h2>
|
||||
<p>创建后即可离开,完成时下方列表会自动更新。</p>
|
||||
<GeneratePersonalReportButton onCreated={() => void load()} />
|
||||
</section>
|
||||
<section className="report-center-section">
|
||||
<div className="report-center-section-heading">
|
||||
<h2>报告记录</h2>
|
||||
<h2>过往的报告</h2>
|
||||
<button type="button" onClick={() => void load(true)} aria-label="刷新报告列表">
|
||||
{state.phase === "loading" ? <InlineSpinner size={14} /> : <RefreshCw aria-hidden="true" />}刷新
|
||||
</button>
|
||||
@@ -169,11 +174,7 @@ export function PersonalReportCenter() {
|
||||
)}
|
||||
|
||||
{state.reports.length === 0 ? (
|
||||
<div className="report-center-empty">
|
||||
<Clock3 aria-hidden="true" className="size-7" />
|
||||
<h3>还没有个人报告</h3>
|
||||
<p>点击“生成完整报告”,创建后即可离开;完成时这里会自动更新。</p>
|
||||
</div>
|
||||
<p className="report-center-empty">还没有个人报告。</p>
|
||||
) : (
|
||||
<div className="report-center-list">
|
||||
{state.reports.map((report) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "@/lib/report-chart-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
|
||||
import {
|
||||
buildLongformOutline,
|
||||
type LongformHeading,
|
||||
@@ -104,11 +106,15 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
|
||||
|
||||
const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
section,
|
||||
forceVisible,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
/** 「到底部」 mounts every section at once so the jump lands on the real end. */
|
||||
forceVisible: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [visible, setVisible] = useState(section.eager);
|
||||
const [seen, setVisible] = useState(section.eager);
|
||||
const visible = seen || forceVisible;
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
@@ -170,6 +176,10 @@ function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
|
||||
|
||||
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
// Synchronous on purpose: the caller scrolls to scrollHeight right after, and
|
||||
// that height is only final once every lazy section has committed.
|
||||
const revealAll = useCallback(() => flushSync(() => setRevealed(true)), []);
|
||||
const lead = useMemo(
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
|
||||
[outline],
|
||||
@@ -185,9 +195,10 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
|
||||
</section>
|
||||
) : null}
|
||||
{outline.sections.filter((section) => !section.eager).map((section) => (
|
||||
<LazyMarkdownSection key={section.id} section={section} />
|
||||
<LazyMarkdownSection forceVisible={revealed} key={section.id} section={section} />
|
||||
))}
|
||||
</article>
|
||||
<ReportEdgeJump revealAll={revealAll} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,15 +37,15 @@ export function ReportActions({
|
||||
<ArrowLeft aria-hidden="true" />返回报告中心
|
||||
</Link>
|
||||
<div className="personal-report-action-end">
|
||||
{/* Labelled, outlined and full height (2026-09-29): the icon-only ghost
|
||||
button it replaces was the one control people could not find. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
aria-label="导出报告"
|
||||
title="导出"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
<Download aria-hidden="true" />导出
|
||||
</Button>
|
||||
{exportOpen && <ReportExportDrawer blocks={blocks} reportDate={reportDate} onClose={() => setExportOpen(false)} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
/** Within this many px of the end the button flips to 「回到顶部」. */
|
||||
export const REPORT_EDGE_JUMP_NEAR_END_PX = 480;
|
||||
|
||||
export type ReportEdgeDirection = "end" | "top";
|
||||
|
||||
export function reportEdgeDirection(scroller: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">): ReportEdgeDirection {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= REPORT_EDGE_JUMP_NEAR_END_PX ? "top" : "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* One floating control on the reader: 「到底部」 until the reader is near the
|
||||
* end, then 「回到顶部」 — never both at once. Not a scroll follower: it moves
|
||||
* only when clicked, and it owns no second scroll anchor (AGENTS §6).
|
||||
*
|
||||
* Sections below the fold render lazily, so the document is short until they
|
||||
* mount. `revealAll` must synchronously mount every section before the jump,
|
||||
* or the scroll would stop wherever the placeholders ended.
|
||||
*/
|
||||
export function ReportEdgeJump({ revealAll }: Readonly<{ revealAll: () => void }>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [direction, setDirection] = useState<ReportEdgeDirection>("end");
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
if (!scroller) return;
|
||||
const update = () => setDirection(reportEdgeDirection(scroller));
|
||||
scroller.addEventListener("scroll", update, { passive: true });
|
||||
return () => scroller.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
function jump() {
|
||||
if (direction === "top") {
|
||||
ref.current?.closest<HTMLElement>(".personal-report-reader")?.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
revealAll();
|
||||
const scroller = ref.current?.closest<HTMLElement>(".personal-report-reader");
|
||||
scroller?.scrollTo({ top: scroller.scrollHeight });
|
||||
}
|
||||
|
||||
const toEnd = direction === "end";
|
||||
return (
|
||||
<div ref={ref} className="report-edge-jump personal-report-screen-only">
|
||||
<button type="button" className="report-edge-jump__button" onClick={jump}>
|
||||
{toEnd ? <ArrowDown aria-hidden="true" /> : <ArrowUp aria-hidden="true" />}
|
||||
{toEnd ? "到底部" : "回到顶部"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { detectPrintRestriction } from "@/lib/client-report-export";
|
||||
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
|
||||
import { exportFilename, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { exportByteSize, exportFilename, formatExportSize, joinExportBlocks, presetSelection, type ReportExportBlock, type ReportExportPreset } from "@/lib/report-export-blocks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const PRESETS = { common: "常用", charts: "全部分盘", tables: "仅核对表" } as const;
|
||||
@@ -23,6 +23,8 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
const selected = blocks.filter(block => selection.has(block.id));
|
||||
const allSelected = selected.length === blocks.length;
|
||||
const chars = (items: readonly ReportExportBlock[]) => items.reduce((sum, block) => sum + block.chars, 0);
|
||||
const totalSize = useMemo(() => formatExportSize(exportByteSize(blocks)), [blocks]);
|
||||
const selectedSize = useMemo(() => formatExportSize(exportByteSize(blocks.filter(block => selection.has(block.id)))), [blocks, selection]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
@@ -68,7 +70,7 @@ export function ReportExportDrawer({ blocks, reportDate, onClose }: {
|
||||
<header className="report-export-header">
|
||||
<div className="report-export-heading"><h2 id="report-export-title">分块导出</h2><Button variant="ghost" onClick={onClose} aria-label="关闭分块导出">关闭</Button></div>
|
||||
<p id="report-export-description">勾选要带走的章节,导出成一个 .md 文件。全选就是整篇。</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字</p>
|
||||
<p className="report-export-count" aria-live="polite">共 {blocks.length} 块 · {chars(blocks)} 字 · {totalSize} · 已选 {selected.length}/{blocks.length} · {chars(selected)} 字 · {selected.length ? selectedSize : "0 KB"}</p>
|
||||
<div className="report-export-presets" aria-label="导出套餐">
|
||||
{(Object.keys(PRESETS) as ReportExportPreset[]).map(key => <Button key={key} variant="outline" aria-pressed={preset === key} onClick={() => choose(key)}>{PRESETS[key]}</Button>)}
|
||||
<Button variant="ghost" onClick={() => { setPreset(null); setSelection(allSelected ? new Set() : new Set(blocks.map(block => block.id))); }}>{allSelected ? "取消全选" : "全选"}</Button>
|
||||
|
||||
@@ -109,3 +109,14 @@ export function presetSelection(preset: ReportExportPreset, blocks: readonly Rep
|
||||
export function exportFilename(reportDate: string | null | undefined, isFull: boolean): string {
|
||||
return `${personalReportMarkdownFilename(reportDate)}${isFull ? "" : "-节选"}`;
|
||||
}
|
||||
|
||||
/** UTF-8 bytes of exactly what the download would contain; not a per-character guess. */
|
||||
export function exportByteSize(blocks: readonly ReportExportBlock[]): number {
|
||||
return blocks.length ? new TextEncoder().encode(joinExportBlocks(blocks)).length : 0;
|
||||
}
|
||||
|
||||
/** 「约 186 KB」 / 「约 1.4 MB」; under 1 KB still reads 「约 1 KB」 so it never says 0. */
|
||||
export function formatExportSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `约 ${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `约 ${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -158,7 +158,11 @@ test("repeat clicks are guarded while a request is in flight", () => {
|
||||
});
|
||||
|
||||
test("entry stays on one line and sends failures to the existing toast", () => {
|
||||
assert.match(componentSource, /shrink-0/);
|
||||
// 原值:`assert.match(componentSource, /shrink-0/)`(按钮在 46px 标题栏里不被挤压)
|
||||
// 新值:`assert.doesNotMatch(componentSource, /shrink-0/)`;`whitespace-nowrap` 保留
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-1——按钮从标题栏挪进页面主体的生成卡片居中,
|
||||
// 不再与标题争宽;「一行不折」这条要求没变
|
||||
assert.doesNotMatch(componentSource, /shrink-0/);
|
||||
assert.match(componentSource, /whitespace-nowrap/);
|
||||
assert.match(componentSource, /toast\.error\(outcome\.message\)/);
|
||||
assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/);
|
||||
|
||||
@@ -430,11 +430,14 @@ test("ready reports expose only the back link and accessible export icon", () =>
|
||||
assert.match(actionsSource, /href="\/reports"/);
|
||||
assert.match(actionsSource, /返回报告中心/);
|
||||
assert.match(actionsSource, /ReportExportDrawer/);
|
||||
assert.match(actionsSource, /size="icon"/);
|
||||
// 原值:`assert.match(actionsSource, /size="icon"/)` 与 `/title="导出"/`
|
||||
// 新值:不再是图标按钮(doesNotMatch size="icon"),可见文字「导出」,描边样式
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-3——图标按钮太不显眼,产品要求做明显
|
||||
assert.doesNotMatch(actionsSource, /size="icon"/);
|
||||
assert.match(actionsSource, /variant="outline"/);
|
||||
assert.match(actionsSource, /<Download aria-hidden="true" \/>导出/);
|
||||
assert.doesNotMatch(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL|#export|reportId|reportTitle/);
|
||||
assert.match(actionsSource, /<Download aria-hidden="true" \/>/);
|
||||
assert.match(actionsSource, /aria-label="导出报告"/);
|
||||
assert.match(actionsSource, /title="导出"/);
|
||||
assert.doesNotMatch(actionsSource, /RawAppendixDownload|printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|useSyncExternalStore|detectPrintRestriction/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ class HostElement {
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
scrollIntoView() {}
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
readonly scrollCalls: { top?: number }[] = [];
|
||||
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
|
||||
/** Class selectors only (`.a` / `.a.b`); enough for components that find their scroller. */
|
||||
closest(selector: string): HostElement | null {
|
||||
const wanted = selector.split(".").filter(Boolean);
|
||||
const classes = (this.getAttribute("class") ?? "").split(/\s+/);
|
||||
if (wanted.every(name => classes.includes(name))) return this;
|
||||
return this.parentNode?.closest(selector) ?? null;
|
||||
}
|
||||
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
|
||||
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
|
||||
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer";
|
||||
import { ReportActions } from "../src/components/personal-report/report-actions";
|
||||
import { buildReportExportBlocks, exportFilename, joinExportBlocks } from "../src/lib/report-export-blocks";
|
||||
import { buildReportExportBlocks, exportByteSize, exportFilename, formatExportSize, joinExportBlocks } from "../src/lib/report-export-blocks";
|
||||
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
@@ -110,7 +110,11 @@ test("reader actions keep only the accessible export icon", async () => {
|
||||
assert.deepEqual(h.errors, []);
|
||||
const exportButton = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "导出报告");
|
||||
assert.ok(exportButton);
|
||||
assert.equal(exportButton?.getAttribute("title"), "导出");
|
||||
// 原值:`title` 属性等于「导出」(只有图标的按钮靠原生提示说明用途)
|
||||
// 新值:按钮自己的可见文字就是「导出」,不再挂 title
|
||||
// 原因:TASK-report-reader-polish-20260929 §3-3——产品反馈图标按钮找不到,改为带文字的描边按钮
|
||||
assert.equal(exportButton?.text, "导出");
|
||||
assert.equal(exportButton?.getAttribute("title"), null);
|
||||
assert.ok(!h.container.text.includes("下载原始附录"));
|
||||
assert.ok(!h.container.text.includes("打印"));
|
||||
await h.event(exportButton!);
|
||||
@@ -158,3 +162,29 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
|
||||
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
|
||||
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
|
||||
});
|
||||
|
||||
test("the drawer shows the real download size, not a character count (BUG-1094)", async () => {
|
||||
const body = blocks.filter(block => block.group === "body");
|
||||
const charts = blocks.filter(block => block.group === "chart");
|
||||
assert.ok(charts.length > 0, "golden carries chart blocks");
|
||||
// Exact bytes of what downloadMarkdownReport would receive.
|
||||
assert.equal(exportByteSize(body), new TextEncoder().encode(joinExportBlocks(body)).length);
|
||||
assert.equal(exportByteSize([]), 0);
|
||||
// Chinese is three bytes a character, so bytes run past the character count.
|
||||
const bodyChars = body.reduce((sum, block) => sum + block.chars, 0);
|
||||
assert.ok(exportByteSize(body) > bodyChars);
|
||||
assert.equal(formatExportSize(186 * 1024), "约 186 KB");
|
||||
assert.equal(formatExportSize(300), "约 1 KB");
|
||||
assert.equal(formatExportSize(Math.round(1.44 * 1024 * 1024)), "约 1.4 MB");
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ReportExportDrawer blocks={blocks} onClose={() => {}} />);
|
||||
const count = h.elements().find(node => node.getAttribute("class") === "report-export-count");
|
||||
assert.ok(count);
|
||||
assert.ok(count.text.includes(formatExportSize(exportByteSize(blocks))), count.text);
|
||||
assert.ok(count.text.includes(formatExportSize(exportByteSize(body))), count.text);
|
||||
await h.event(button(h, "全选"));
|
||||
await h.event(button(h, "取消全选"));
|
||||
assert.ok(h.elements().find(node => node.getAttribute("class") === "report-export-count")?.text.endsWith("0 KB"));
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view";
|
||||
import { REPORT_EDGE_JUMP_NEAR_END_PX, reportEdgeDirection } from "../src/components/personal-report/report-edge-jump";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
// TASK-report-reader-polish-20260929 (BUG-1094): generate entry in the page
|
||||
// body, 到底部 that lands on the real end, two-level TOC, report-only table bands.
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const centre = read("../src/components/personal-report/personal-report-center.tsx");
|
||||
const globals = read("../src/app/globals.css");
|
||||
|
||||
function rule(selector: string): string {
|
||||
const start = globals.indexOf(`${selector} {`);
|
||||
assert.notEqual(start, -1, `missing rule: ${selector}`);
|
||||
return globals.slice(start, globals.indexOf("}", start));
|
||||
}
|
||||
|
||||
test("the report centre has one generate entry, in a card above the archive", () => {
|
||||
assert.doesNotMatch(centre, /actions=\{<GeneratePersonalReportButton/, "the header button is gone");
|
||||
assert.equal(centre.match(/<GeneratePersonalReportButton/g)?.length, 1);
|
||||
const card = centre.indexOf('className="report-center-create"');
|
||||
const archive = centre.indexOf("<h2>过往的报告</h2>");
|
||||
assert.ok(card > 0 && archive > card, "generate card comes first, then 过往的报告");
|
||||
assert.ok(centre.indexOf("<GeneratePersonalReportButton", card) < archive);
|
||||
assert.doesNotMatch(centre, /报告记录|点击“生成完整报告”/, "old heading and mismatched button name are gone");
|
||||
assert.match(rule(".report-center-create"), /align-items: center/);
|
||||
const button = read("../src/components/personal-report/generate-personal-report-button.tsx");
|
||||
assert.doesNotMatch(button, /variant="outline"|size="sm"/, "the page's one filled action");
|
||||
});
|
||||
|
||||
test("the edge jump flips near the end and never shows both directions", () => {
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 0, scrollHeight: 10_000, clientHeight: 800 }), "end");
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 10_000 - 800 - REPORT_EDGE_JUMP_NEAR_END_PX, scrollHeight: 10_000, clientHeight: 800 }), "top");
|
||||
assert.equal(reportEdgeDirection({ scrollTop: 0, scrollHeight: 600, clientHeight: 800 }), "top", "a short report has no bottom to jump to");
|
||||
});
|
||||
|
||||
test("到底部 mounts every lazy section before it scrolls, in one click", async () => {
|
||||
const markdown = ["# 报告", "", "## 摘要", "", "开头", "",
|
||||
...Array.from({ length: 6 }, (_, index) => [`## 第${index + 1}章`, "", `正文${index + 1}`, ""]).flat()].join("\n");
|
||||
const originalObserver = globalThis.IntersectionObserver;
|
||||
// Nothing ever intersects: without the jump, lazy sections stay placeholders.
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", {
|
||||
configurable: true, writable: true,
|
||||
value: class { observe() {} disconnect() {} },
|
||||
});
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
const scroller = h.container;
|
||||
scroller.setAttribute("class", "personal-report-reader");
|
||||
scroller.scrollHeight = 12_345;
|
||||
await h.render(<PersonalReportMarkdownView markdown={markdown} />);
|
||||
assert.ok(!h.container.text.includes("正文6"), "far sections start unmounted");
|
||||
const buttons = h.elements().filter(node => node.tagName === "BUTTON" && /到底部|回到顶部/.test(node.text));
|
||||
assert.equal(buttons.length, 1);
|
||||
assert.equal(buttons[0].text, "到底部");
|
||||
await h.event(buttons[0]);
|
||||
assert.ok(h.container.text.includes("正文6"), "every section rendered by the time the scroll runs");
|
||||
assert.deepEqual(scroller.scrollCalls, [{ top: 12_345 }]);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
await h.close();
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, writable: true, value: originalObserver });
|
||||
}
|
||||
});
|
||||
|
||||
test("the edge jump stays off paper", () => {
|
||||
const source = read("../src/components/personal-report/report-edge-jump.tsx");
|
||||
assert.match(source, /className="report-edge-jump personal-report-screen-only"/);
|
||||
assert.match(rule(".report-edge-jump"), /position: fixed/);
|
||||
});
|
||||
|
||||
test("TOC chapters and sections read as two levels", () => {
|
||||
assert.match(globals, /\.personal-report-toc li\[data-level="2"\] > a \{ color: var\(--color-ink\); font-weight: 600; \}/);
|
||||
assert.match(globals, /\.personal-report-toc li\[data-level="3"\] \{ padding-left: var\(--space-5\); \}/);
|
||||
});
|
||||
|
||||
test("zebra bands live on report tables only, never on chat tables", () => {
|
||||
assert.match(globals, /\.personal-report-table-wrap tbody tr:nth-child\(even\),\n\.personal-report-fact-tables tbody tr:nth-child\(even\) \{/);
|
||||
assert.match(globals, /\.personal-report-table-wrap thead th,\n\.personal-report-fact-tables thead th \{\n background: color-mix/);
|
||||
assert.doesNotMatch(globals, /\.markdown-table tbody tr:nth-child\(even\)/);
|
||||
assert.doesNotMatch(globals, /\.markdown-table thead th \{[^}]*background/);
|
||||
});
|
||||
Reference in New Issue
Block a user