报告和合盘的中英切换能看出选中了哪个(BUG-1445)。
选中项改认页签自己的标记。边框和文字用主题色,背景保持中性。整篇和自选重新描上主题色边框。 检查:language-switch-selected 1 通过;report-buttons-scope 与 class-name-definition-contract 共 9 通过。两轮都是 0 失败,退出码 0。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
38097ca82f
commit
10ecb091a8
@@ -19642,3 +19642,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
|
||||
|
||||
## BUG-1445 | 报告和合盘的中英切换看不出选中了哪个
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-10-26
|
||||
- 最近更新:2026-10-26
|
||||
- 影响面:个人报告的语言切换、导出抽屉里的语言分段和「整篇 / 自选」、合盘报告的语言按钮。勾选章节的方框没改。
|
||||
- 用户现象:中文和 English 看起来一样,看不出当前是哪一种。导出时「整篇报告」和「自选章节」也没有主题色边框。
|
||||
- 触发条件:这三处换成页签之后,页签用 `data-active` 和 `aria-selected` 表示选中。旧样式仍只认 `aria-pressed` 或 `aria-checked`,而且未分层的透明底盖住了组件库的选中色。
|
||||
- 根因:选中规则对不上页签实际写下的标记。导出范围的两张卡片也不再带原来的卡片类名。
|
||||
- 修复:三处选中规则改为认 `data-active` 或 `aria-selected="true"`。边框和文字用主题色,背景保持中性。整篇和自选重新带上卡片类名,选中时描出主题色边框。
|
||||
- 验证:`frontend/tests/language-switch-selected.test.tsx` 渲染这三处和导出范围,选中项与未选中项的标记不同,且只有选中项命中主题色规则。`report-buttons-scope-20260928.test.ts` 与 `class-name-definition-contract.test.ts` 通过。
|
||||
- 防复发:上面的渲染测试,以及源码里不再保留这三组只认旧标记的选中规则。
|
||||
- 相关记录:issue #111 验收 U2。#108、#109。BUG-1078。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。
|
||||
|
||||
|
||||
@@ -13,3 +13,13 @@
|
||||
断言三栏(`sidebar-contract.test.ts`「selects desktop and tablet shell widths」):原值是网格列分出 288 / 240 / 64,并检查 `minSize={SIDEBAR_WIDTH_MIN}`。新值检查横向 flex、`shell-sidebar-slot` / `shell-main-slot`,以及宽度上下限常量。原因是可拖面板不再包住整页,页面在分栏到达或跨断点时不再卸载。
|
||||
|
||||
全量 `npm test`、`npm run build`、`npm run test:db`、Python 快速门留到本单全部改完再跑。
|
||||
|
||||
## U2 · 中英切换能看出选中(BUG-1445)
|
||||
|
||||
报告语言、导出语言、合盘语言,以及导出的「整篇 / 自选」,选中项改认页签自己的标记。边框和文字用主题色,背景保持中性。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 选中态 | `node node_modules/tsx/dist/cli.mjs --test tests/language-switch-selected.test.tsx`;同一命令再跑 `tests/report-buttons-scope-20260928.test.ts tests/class-name-definition-contract.test.ts` | 1 通过;随后 9 通过。两轮都是 0 失败,退出码 0 |
|
||||
|
||||
断言三栏(`report-buttons-scope-20260928.test.ts`):原值选中规则只认 `aria-checked="true"`。新值认 `data-active` 或 `aria-selected="true"`。原因是页签不设 `aria-checked`,旧规则对不上。
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
|
||||
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
|
||||
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U2 已提交) | `codex/ui-kit-onboarding-fix-20261026` |
|
||||
| —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` |
|
||||
| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
|
||||
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd |
|
||||
|
||||
+3
-3
@@ -609,13 +609,13 @@ Action results and errors never take page layout. They are toasts from `src/lib/
|
||||
### Personal report reader
|
||||
|
||||
- **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).
|
||||
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** a component-library Sheet. Desktop slides in from the right (`data-side="right"`, width `min(520px, 92vw)`, max 520px). Below 767px it slides up from the bottom (`data-side="bottom"`, up to 85dvh). App colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); segmented tabs for language (`aria-label="导出语言"`, 中文 / English) only when an English edition exists. They start on the edition being read and do not switch the page. There is no AI caption. Without an English edition, opening the sheet raises one info toast (id `report-export-english`) saying why. Then two segmented tabs (`aria-label="导出范围"`) 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」 (the second tab also carries 「只带走需要的部分」); only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The footer button does not show a file size: 「导出整篇」 / 「导出 N 项」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
|
||||
- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** a component-library Sheet. Desktop slides in from the right (`data-side="right"`, width `min(520px, 92vw)`, max 520px). Below 767px it slides up from the bottom (`data-side="bottom"`, up to 85dvh). App colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); segmented tabs for language (`aria-label="导出语言"`, 中文 / English) only when an English edition exists. They start on the edition being read and do not switch the page. There is no AI caption. Without an English edition, opening the sheet raises one info toast (id `report-export-english`) saying why. Then two segmented tabs (`aria-label="导出范围"`) 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」 (the second tab also carries 「只带走需要的部分」); only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The footer button does not show a file size: 「导出整篇」 / 「导出 N 项」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). The language segment and the 整篇 / 自选 cards (`report-export-mode`) match `[data-active]` or `[aria-selected="true"]`, the same mark the chart-page tabs use. They do not look for `aria-checked` or `aria-pressed` alone. Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign.
|
||||
- **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`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
|
||||
|
||||
- **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.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. The selected segment keeps a neutral background; its text is `--color-action` and a 1px inset edge is `--color-action-strong`, matched on `[data-active]` or `[aria-selected="true"]`. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
|
||||
- **导出框里的英文版提示 (2026-10-25, K5 / D8):** the caption and the 「切到 English」 band are gone. A report without an English edition still raises one info toast (id `report-export-english`) when the sheet opens. Switching the sheet's language still resets the checklist to that edition's blocks; the file name still gains `-EN`.
|
||||
- **Opening a report (2026-09-30, BUG-1106):** one waiting screen, `ReportOpening` — the 46px header 「个人报告」 over a centred spinner and 「正在加载报告…」. The route's `loading.tsx` and the reader's first fetch both render it, so nothing swaps or jumps between them.
|
||||
- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the sheet slides up from the bottom and may take up to 85dvh. There is no description line and no file size. 关闭 is a × icon (accessible name 「关闭导出」).
|
||||
@@ -1301,7 +1301,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
|
||||
## 婚恋合盘报告(2026-10-19)
|
||||
|
||||
选人页不重复标题和副标题,页名在顶栏(2026-10-25)。两个人的选择框左右并排,中间一个叉;窄屏改成上下排,叉仍在正中。各自是下拉,最多二十人。资料不全的人不能选;已经在另一边的人不能再选。下面是「新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行换成去星盘档案删人。「开始合盘」在桌面和手机上都居中,按钮上不写价格。点了先问「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮换成「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。第一次打开时两个头像仍从两侧靠拢;减少动态效果时只淡入。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
|
||||
选人页不重复标题和副标题,页名在顶栏(2026-10-25)。两个人的选择框左右并排,中间一个叉;窄屏改成上下排,叉仍在正中。各自是下拉,最多二十人。资料不全的人不能选;已经在另一边的人不能再选。下面是「新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行换成去星盘档案删人。「开始合盘」在桌面和手机上都居中,按钮上不写价格。点了先问「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮换成「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。选中的那一个边框是 `--color-action-strong`、文字是 `--color-action`,背景仍是画布色,认 `[data-active]` 或 `[aria-selected="true"]`,不铺 `--color-action-soft`。第一次打开时两个头像仍从两侧靠拢;减少动态效果时只淡入。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
|
||||
|
||||
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
|
||||
|
||||
|
||||
@@ -4051,18 +4051,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
|
||||
.report-export-segment { display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
|
||||
.report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
|
||||
.report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-action); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), inset 0 0 0 1px var(--color-action-strong); }
|
||||
.report-export-segment button:is([data-active], [aria-selected="true"]) { background: var(--color-canvas); color: var(--color-action); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), inset 0 0 0 1px var(--color-action-strong); }
|
||||
.report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
|
||||
.report-export-modes { display: grid; grid-template-columns: 1fr 1fr; width: 100%; height: auto; }
|
||||
.report-export-modes button { height: auto; white-space: normal; }
|
||||
.report-export-modes strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
|
||||
.report-export-modes small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; }
|
||||
.report-export-mode[aria-checked="true"] { border-color: var(--color-action-strong); background: transparent; }
|
||||
.report-export-mode:is([data-active], [aria-selected="true"]) { border-color: var(--color-action-strong); background: transparent; color: var(--color-action); }
|
||||
.report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; }
|
||||
.report-export-mode small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.report-export-radio { flex-shrink: 0; width: 18px; height: 18px; border: 1.5px solid var(--color-border-strong); border-radius: 50%; }
|
||||
.report-export-mode[aria-checked="true"] .report-export-radio { border: 5px solid var(--color-action-strong); }
|
||||
.report-export-mode:is([data-active], [aria-selected="true"]) .report-export-radio { border: 5px solid var(--color-action-strong); }
|
||||
.report-export-list { display: grid; gap: var(--space-1); }
|
||||
.report-export-group + .report-export-group { border-top: 1px solid var(--color-border); padding-top: var(--space-1); }
|
||||
.report-export-group-head { display: flex; align-items: center; }
|
||||
@@ -4457,7 +4457,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
/* 中文 | English: a two-segment switch in the reader's action bar, only on reports with both editions. */
|
||||
.report-language-switch { display: inline-flex; padding: 2px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas); }
|
||||
.report-language-switch button { min-height: 40px; min-width: 64px; padding: 0 var(--space-3); border: 0; border-radius: 999px; background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
|
||||
.report-language-switch button[aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); font-weight: 600; }
|
||||
.report-language-switch button:is([data-active], [aria-selected="true"]) { background: transparent; color: var(--color-action); font-weight: 600; box-shadow: inset 0 0 0 1px var(--color-action-strong); }
|
||||
.report-language-switch button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
|
||||
.personal-report-action-end > span {
|
||||
max-width: 440px;
|
||||
@@ -4742,7 +4742,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.synastry-lang button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); }
|
||||
.synastry-lang button:is([data-active], [aria-selected="true"]) { border-color: var(--color-action-strong); background: var(--color-canvas); color: var(--color-action); }
|
||||
.synastry-section h2, .synastry-talk h2 { margin: 0 0 8px; color: var(--color-ink); font-size: 19px; font-weight: 500; }
|
||||
.synastry-section p, .synastry-talk li { margin: 0; color: var(--color-ink); font-size: 16px; line-height: 1.85; }
|
||||
.synastry-section .lead { margin-top: 0; color: var(--color-ink-secondary); font-size: 14.5px; }
|
||||
|
||||
@@ -132,10 +132,10 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
|
||||
<Tabs value={mode} onValueChange={(value) => { if (value === "whole" || value === "pick") setMode(value); }}>
|
||||
<TabsList variant="default" aria-label="导出范围" className="report-export-modes">
|
||||
<TabsTrigger value="whole">
|
||||
<TabsTrigger value="whole" className="report-export-mode">
|
||||
<span><strong>整篇报告</strong><small>{summary}</small></span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pick">
|
||||
<TabsTrigger value="pick" className="report-export-mode">
|
||||
<span><strong>自选章节</strong><small>只带走需要的部分</small></span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { ReportActions } from "../src/components/personal-report/report-actions.tsx";
|
||||
import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer.tsx";
|
||||
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
|
||||
import { buildReportExportBlocks } from "../src/lib/report-export-blocks.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
type Node = ReturnType<ReturnType<typeof createClientLifecycleHarness>["elements"]>[number];
|
||||
|
||||
function splitTop(input: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
if (char === "(") depth += 1;
|
||||
else if (char === ")") depth -= 1;
|
||||
else if (char === "," && depth === 0) {
|
||||
parts.push(input.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
parts.push(input.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function matchesCompound(node: Node, compound: string): boolean {
|
||||
const isAt = compound.indexOf(":is(");
|
||||
let rest = compound;
|
||||
if (isAt >= 0) {
|
||||
rest = compound.slice(0, isAt);
|
||||
const inner = compound.slice(isAt + 4, compound.lastIndexOf(")"));
|
||||
if (!splitTop(inner).some((option) => matchesCompound(node, option.trim()))) return false;
|
||||
}
|
||||
const parts = rest.match(/[a-zA-Z][\w-]*|\.[\w-]+|\[[^\]]+\]/g) ?? [];
|
||||
if (parts.length === 0) return isAt >= 0;
|
||||
const classes = (node.getAttribute("class") ?? "").split(/\s+/);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(".")) {
|
||||
if (!classes.includes(part.slice(1))) return false;
|
||||
} else if (part.startsWith("[")) {
|
||||
const body = part.slice(1, -1);
|
||||
const named = /^([\w-]+)(?:="([^"]*)")?$/.exec(body);
|
||||
if (!named || !node.hasAttribute(named[1])) return false;
|
||||
if (named[2] !== undefined && node.getAttribute(named[1]) !== named[2]) return false;
|
||||
} else if (node.tagName.toLowerCase() !== part.toLowerCase()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectorCompounds(selector: string): string[] {
|
||||
const compounds: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
const source = selector.trim();
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "(") depth += 1;
|
||||
else if (char === ")") depth -= 1;
|
||||
else if (/\s/.test(char) && depth === 0) {
|
||||
if (index > start) compounds.push(source.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
if (start < source.length) compounds.push(source.slice(start));
|
||||
return compounds.filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesSelector(node: Node, selector: string): boolean {
|
||||
const compounds = selectorCompounds(selector);
|
||||
let cursor: Node | null = node;
|
||||
for (let index = compounds.length - 1; index >= 0; index -= 1) {
|
||||
const start = index === compounds.length - 1 ? cursor : cursor?.parentNode ?? null;
|
||||
let found: Node | null = null;
|
||||
for (let walk = start; walk; walk = index === compounds.length - 1 ? null : walk.parentNode) {
|
||||
if (matchesCompound(walk, compounds[index])) {
|
||||
found = walk;
|
||||
break;
|
||||
}
|
||||
if (index === compounds.length - 1) break;
|
||||
}
|
||||
if (!found) return false;
|
||||
cursor = found;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function themeRules() {
|
||||
const clean = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
const rules: { selector: string; body: string }[] = [];
|
||||
for (const hit of clean.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
||||
const selector = hit[1].trim();
|
||||
if (!selector || selector.includes("@")) continue;
|
||||
rules.push({ selector, body: hit[2] });
|
||||
}
|
||||
return rules.filter((rule) => /\[data-active\]|\[aria-selected="true"\]/.test(rule.selector)
|
||||
&& /--color-action-strong|--color-action\b/.test(rule.body));
|
||||
}
|
||||
|
||||
function hitsTheme(node: Node): boolean {
|
||||
return themeRules().some((rule) => splitTop(rule.selector).some((selector) => matchesSelector(node, selector.trim())));
|
||||
}
|
||||
|
||||
function tabsIn(nodes: Node[], owner: Node) {
|
||||
return nodes.filter((node) => node.getAttribute("role") === "tab" && owner.contains(node));
|
||||
}
|
||||
|
||||
function pair(nodes: Node[], owner: Node, label: string) {
|
||||
const tabs = tabsIn(nodes, owner);
|
||||
const selected = tabs.find((node) => node.getAttribute("aria-selected") === "true");
|
||||
const unselected = tabs.find((node) => node.getAttribute("aria-selected") === "false");
|
||||
assert.ok(selected, `${label} selected`);
|
||||
assert.ok(unselected, `${label} unselected`);
|
||||
assert.equal(selected.hasAttribute("data-active"), true, `${label} selected mark`);
|
||||
assert.equal(unselected.hasAttribute("data-active"), false, `${label} unselected mark`);
|
||||
assert.notEqual(
|
||||
`${selected.getAttribute("class")}|${selected.getAttribute("aria-selected")}|${selected.hasAttribute("data-active")}`,
|
||||
`${unselected.getAttribute("class")}|${unselected.getAttribute("aria-selected")}|${unselected.hasAttribute("data-active")}`,
|
||||
`${label} class or attribute`,
|
||||
);
|
||||
assert.equal(hitsTheme(selected), true, `${label} theme rule`);
|
||||
assert.equal(hitsTheme(unselected), false, `${label} unselected theme rule`);
|
||||
}
|
||||
|
||||
test("the three language switches and the export scope card show which one is selected", async () => {
|
||||
assert.doesNotMatch(css, /\.report-language-switch button\[aria-pressed/);
|
||||
assert.doesNotMatch(css, /\.report-export-segment button\[aria-checked/);
|
||||
assert.doesNotMatch(css, /\.synastry-lang button\[aria-pressed/);
|
||||
|
||||
const zh = buildReportExportBlocks({ markdown: "# 报告\n\n## 摘要\n\n甲的月亮在第九宫。\n" });
|
||||
const en = buildReportExportBlocks({
|
||||
markdown: "# Report\n\n## Summary\n\nThe Moon sits in the ninth house.\n",
|
||||
language: "en",
|
||||
});
|
||||
const harness = createClientLifecycleHarness();
|
||||
try {
|
||||
await harness.render(
|
||||
<>
|
||||
<ReportActions markdown="# 报告" englishEdition="ready" language="zh" onLanguageChange={() => {}} />
|
||||
<ReportExportDrawer editions={{ zh, en }} englishEdition="ready" onClose={() => {}} />
|
||||
<SynastryReportView
|
||||
reportId="00000000-0000-4000-8000-000000001445"
|
||||
snapshot={{
|
||||
names: { a: "甲", b: "乙" },
|
||||
sections: [{ id: "bond", title: "关系", text: "一段说明。" }],
|
||||
english: { sections: [{ id: "bond", title: "Bond", text: "A note." }] },
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
await harness.idle();
|
||||
const roots = [harness.container, ...[...harness.document.body.childNodes]];
|
||||
const nodes = roots.flatMap((node) => {
|
||||
const walk = (current: Node): Node[] => [current, ...current.childNodes.flatMap((child) => walk(child))];
|
||||
return walk(node);
|
||||
});
|
||||
const list = (label: string) => {
|
||||
const found = nodes.find((node) => node.getAttribute("role") === "tablist" && node.getAttribute("aria-label") === label);
|
||||
assert.ok(found, label);
|
||||
return found;
|
||||
};
|
||||
pair(nodes, list("报告语言"), "报告语言");
|
||||
pair(nodes, list("导出语言"), "导出语言");
|
||||
pair(nodes, list("导出范围"), "导出范围");
|
||||
const synastry = nodes.find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes("synastry-lang"));
|
||||
assert.ok(synastry, "合盘语言");
|
||||
pair(nodes, synastry, "合盘语言");
|
||||
assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n"));
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
@@ -113,11 +113,14 @@ test("the BUG-1078 report-only override is gone; the selected export preset stay
|
||||
// 原值:范围单选卡与语言分段 doesNotMatch(/--color-action-soft|--color-action\b/) 且 match(/--color-ink|--color-canvas/)
|
||||
// 新值:四条选中规则不得用 --color-action-soft / --color-action-fill;描边、圆点、勾选框和语言分段用 --color-action-strong(语言文字用 --color-action);底色只许 transparent / --color-canvas*(勾选框 18px 小方块的 background 可以是 --color-action-strong)
|
||||
// 原因:issue #90 推翻 DESIGN「Selected states use canvas-muted + ink」。BUG-1078 的主语(不得用 --color-action-soft 深色块)保留。旧正则 --color-action\b 会误伤 --color-action-strong
|
||||
// 原值:整篇卡片与语言分段的选中规则只认 aria-checked="true"
|
||||
// 新值:认 data-active 或 aria-selected="true"。勾选框仍认 aria-checked
|
||||
// 原因:导出范围和语言已经是页签,页签不设 aria-checked,旧规则对不上,选中和未选中看起来一样
|
||||
const selected = {
|
||||
mode: rule('.report-export-mode[aria-checked="true"]'),
|
||||
radio: rule('.report-export-mode[aria-checked="true"] .report-export-radio'),
|
||||
mode: rule('.report-export-mode:is([data-active], [aria-selected="true"])'),
|
||||
radio: rule('.report-export-mode:is([data-active], [aria-selected="true"]) .report-export-radio'),
|
||||
box: rule('.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box'),
|
||||
segment: rule('.report-export-segment button[aria-checked="true"]'),
|
||||
segment: rule('.report-export-segment button:is([data-active], [aria-selected="true"])'),
|
||||
};
|
||||
for (const body of Object.values(selected)) {
|
||||
assert.doesNotMatch(body, /--color-action-soft|--color-action-fill\b/);
|
||||
|
||||
Reference in New Issue
Block a user