Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) b7ec8e78ed 星盘页换成组件库:页签、盘型条、可拖分栏和换盘动效(#107)。
Independent Staging Quality Gate / validate (push) Failing after 9m20s
Independent Staging Quality Gate / publish (push) Skipped
四个页签用下划线,盘型条用分段并可以横滑,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍跨满整行。换盘型时星体移到新位置;减少动态效果只淡入。第一次进场不重播。

自测:tsc 退出码 0;frontend 下 npm run lint 0 error(122 警告);定向测试 188 通过、0 失败、0 取消;npm run build 后 / 仍是 Static;首屏 JS gzip-9 为 640131,相对 d25dfdbe 记下的 639127 多 0.16%。没有跑全量 npm test、Python 快速门和 test:db。

Co-Authored-By: Grok (agent loop)
2026-10-25 04:02:14 +08:00
23 changed files with 959 additions and 339 deletions
+10
View File
@@ -1,5 +1,15 @@
# 印度占星 Skill 更新日志
## 2026-10-25 — 星盘页的页签和盘型条换成组件库
- 四个页签仍是星盘、基础信息、大运、西洋盘,当前项有下划线。
- 本命、D9、月亮这些盘型是一排可以横滑的分段。点「全部盘型」从底部打开 23 种盘。
- 桌面(不窄于 1024)上,盘和参数可以拖宽。手机上盘在上、参数在下。行星表仍然横跨整行。
- 行星状态用同一种小标记。落陷和带「敌」的用强调色,其余用次要色。没有状态写「—」。
- 点顶栏的日期地点可以改出生资料。表单仍是档案页那一份。
- 换盘型时,星体移到新的宫位。换人时格子重画。大运点开才展开。系统开了减少动态效果时,这些只淡入,星体不飞。第一次打开时的进场不重播。
- Skill 版本不变(BUG-1435)。
## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘
- 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。
+16
View File
@@ -19446,3 +19446,19 @@
- 复发自:无
- 修复版本:分支 `codex/ui-kit-2-chat-20261023`。
## BUG-1435 | 星盘页换成组件库
- 状态:enhancement
- 首次发现:2026-10-25
- 最近更新:2026-10-25
- 影响面:星盘页的四个页签、盘型条、盘和参数的并排、行星表状态列、出生资料编辑、换盘和换人时的动效、大运展开。对话旁边的本命盘仍用原来的绘制。
- 用户现象:页签是下划线。盘型是一排可以横滑的分段,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍横跨整行。状态用同一种小标记,没有状态写「—」。换盘型时星体移到新宫位;开了减少动态效果就只淡入。
- 触发条件:打开星盘页,切换页签、盘型或人物,或点开一段大运。桌面分栏在视口不窄于 1024 且分栏零件载入之后出现。
- 根因:不是缺陷。组件库迁移的星盘页这一张(任务书 K3,决策 D4、D5、D7、D8、D9)。
- 修复:四个页签用下划线页签。盘型条用分段页签加横向滚动,「星盘类型」用底部抽屉。桌面盘和参数放进可拖分栏,按需载入,不记住宽度。本命和月亮的行星表仍是原来的表格,状态列改成同一种徽标;分盘、Bhava、行运和西洋表用组件库表格,并继续跨满整行。上升格写「—」。出生资料改用对话框,表单仍是档案页共用的那一份。换盘型时星体从旧位置移到新位置;换人时格线重画;大运展开用可折叠组件。第一次进场不重播。星盘的线和图仍是手绘。
- 验证:见 `docs/tasks/PROGRESS-ui-kit-3-chart-20261023.md`。坐标断言证明换盘后的星体停在直接画出来的位置。D1 冻结对照没有改基线文件。没有浏览器登录态,不把 1024、1280、390 的截图写成已经看过。
- 防复发:`frontend/tests/chart-glyph-motion.test.ts` 锁住最终坐标。`frontend/tests/chart-varga-table.test.tsx` 锁住 D1 对照和「—」。`frontend/tests/chart-table-span-20261023.test.tsx` 锁住行星表跨满整行。白名单测试锁住名单只减不增。
- 相关记录:issue #107。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K3。行星表跨行沿用 BUG-1422,不另开编号。前置 #106 的对话页、#102 的整行、#99 的第一次进场。
- 复发自:无
- 修复版本:分支 `codex/ui-kit-3-chart-20261023`。
@@ -0,0 +1,64 @@
# PROGRESS · 组件库 K3 星盘页(2026-10-25)
工单 #107。分支 `codex/ui-kit-3-chart-20261023`。基线 `origin/staging` `d25dfdbe1abeef1adbe92cb39b52620739a600b1`(#102 行星表跨满整行、#105 侧栏、#99 启程动效、#106 对话页已在这个提交里)。只做任务书 **K3 星盘页**。K4 及之后没做。没有重做对话页,没有给星盘档案加排序列。
## 结论
星盘页四个页签和盘型条换成组件库。桌面上盘和参数可以拖宽,手机上上下排。行星表仍然横跨整行。换盘型时星体会移到新位置;系统开了减少动态效果就只淡入。第一次打开时的进场不重播。
## 做了什么
- 四个页签用 `Tabs`,`variant="line"`。盘还没到时四个都不可选。
- 盘型条是分段式 `Tabs`(灰底槽,选中的一格浮起),放在横向 `ScrollArea` 里。23 种的顺序不变。「星盘类型」是底部 `Sheet`。没有用描边的 `ToggleGroup`。
- 桌面(不窄于 1024)上,盘和参数放进 `ResizablePanelGroup`,默认约 58% / 42%,最窄约 32% / 24%。宽度不记到本机。分栏零件在这一块载入后再出现,不进首页同步包。更窄或还没载入时,盘在上、参数在下。行星表仍是整行的第三格。
- 本命和月亮的八列表仍是原来的 `<table>`,这样 D1 冻结的基线文件不用改。状态列改成同一种 `Badge`:落陷、带「敌」的用 `destructive`,其余用 `secondary`。没有状态,以及上升格,写「—」,不画第二种标记。悬停仍是「上升点是命盘的起点,不是行星,不判断入旺或落陷」。分盘、Bhava、行运、西洋表用组件库 `Table`。分盘的庙旺、同位和 Bhava 的换宫仍是字。
- 出生资料编辑改用 `Dialog`。点框内不关,按 Esc 或点遮罩会关。表单仍是档案页共用的那一份,输入框没有另做成 `Field` 和 `Input`。
- 换盘型时,星体从旧宫位的坐标移到新宫位,停住后的坐标和直接画出那张盘相同。换人时格线重画,星体不飞。减少动态效果时不做位移。`data-onboarding-draw` 的键没改,第一次进场不重播。大运展开用 `Collapsible`,当前段一开始是开的且不播放展开;之后点开才动。0 年段仍不能展开。已经选中的 Chara 或 Yogini 再点一次会再请求。
- 星盘的线和符号仍是手绘。对话旁边的本命盘仍只复用绘制组件。
- 白名单去掉 5 个星盘页文件,没有加长,测试里写死的基线名单没有改。
- `chart-basics-tab.tsx`、`chart-param-table.tsx`、`chart-symbol-help.tsx` 没有改。参数仍是一张键值表,符号说明仍是原来的气泡。
- 没有改 `.gitea/**`,没有改生产部署,没有动数据库,没有给 `scripts/jyotish_api_server.py` 加方法,没有改 `page.tsx`。
## 断言改动(原值 / 新值 / 原因)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 四个页签禁用 | 4 个 `disabled=""` | 4 个 `aria-disabled="true"` | 页签改用 Tabs。base-ui 把禁用写在 aria-disabled 上 |
| 行星表个数 | 只数原生 `<table className="chart-page-planet-table">` | 原生 table 与 `<Table className="chart-page-planet-table">` 加起来等于外包层 | 分盘、Bhava、行运用组件库表格。本命表仍是原生 table |
| 大运展开 | `<details>` / `details:not([open])` | `data-slot="collapsible-content"`,收起用 `data-closed` | 大运改用 Collapsible。当前段默认展开,点按高度仍是 44px |
| 零年大运 | 零年行没有 `<details` | 零年行没有 Collapsible,展开中的行不含「0 年」 | 零年仍然不能展开 |
| 盘型条 | 原生 `role="tablist"` 里的 button | `data-variant="default"` 的 `role="tab"`,菜单仍是「全部盘型」 | 盘型条改成 Tabs + ScrollArea |
| 星盘类型面板 | 生命周期宿主点开原生 `<dialog>` | 源码是 Sheet;格子静态渲染 23 项;选中后先关上再回调 | 测试宿主的浮层挂不进 body,不能假装点开过 |
| D1 上升格 | 基线对照里是「/」按钮 | 可见「—」,说明句仍在 | 没有状态就写「—」。基线文件不改,只在断言里替换 |
| D1 状态列 | 纯文本 | 同一列换成 Badge;落陷、敌是 destructive,其余是 secondary | 同上,fixture 文件不改 |
| 分盘和月亮的上升格 | 可见「/」 | 可见「—」 | 没有状态就写「—」 |
| 出生资料框 | 源码把点击交给 `chartBirthDialogShouldClose` | `Dialog` 的 `onOpenChange` 收到 false 才关;几何函数仍导出 | 改用对话框。点内容区内边距不关,和原来的结果相同 |
| 白名单 | 含 5 个星盘页文件 | 这 5 个移出 | 它们已经改用组件库。名单只许变短 |
移出的 5 个文件:`chart-birth-editor.tsx`、`chart-dasha-tab.tsx`、`chart-page-view.tsx`、`chart-type-picker.tsx`、`chart-vedic-tab.tsx`。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error |
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面列出的文件 | 188 条,通过 188,失败 0,取消 0 |
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 10.3s,TypeScript 30.8s。`/` 仍是 ○ Static。5 条既有 skill-package-registry 警告,没有改 |
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `d25dfdbe`(上一单记下的数):36 个文件,639127 字节。本单:36 个文件,缺失 0,640131 字节,多 1004(+0.16%)。±2% 上限 651910。`react-resizable-panels` 命中 0。`chart-page-vedic-split` 命中 0 |
定向测试文件:`billing-panel.test.ts`、`chart-bhava.test.tsx`、`chart-birth-editor-and-report-table.test.tsx`、`chart-dasha-switch-layout.test.ts`、`chart-dasha-yogini.test.ts`、`chart-glyph-motion.test.ts`、`chart-moon-transit.test.tsx`、`chart-page-hook.test.ts`、`chart-page-mobile-width-20260929.test.ts`、`chart-page-view.test.tsx`、`chart-planet-glyphs.test.tsx`、`chart-symbol-help.test.tsx`、`chart-table-span-20261023.test.tsx`、`chart-type-picker.test.tsx`、`chart-varga-table.test.tsx`、`chart-view-zero-year.test.ts`、`secondary-page-entry.test.ts`、`surface-feedback-20260929.test.tsx`、`ui-kit-whitelist.test.ts`、`western-wheel-layout.test.ts`。
没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。`tests/test_repo_privacy_markers.py` 退出码 0。`page.tsx` 和 `scripts/jyotish_api_server.py` 没有改。
## 偏离
- 出生资料的输入框仍是档案页共用的那一份,没有拆成第二套 `Field` 和 `Input`。编辑框本身已是对话框。
- 本命和月亮的行星表仍是原生 table。组件库表格用在分盘、Bhava、行运和西洋表。D1 基线文件没有改,状态列和上升格在断言里替换后再对照。
- 「星盘类型」抽屉在这个测试宿主里挂不进 body。合同改为源码接线和 23 个格子的静态渲染。
- 测试用的假文档补了 `head`、`querySelector` 和 `querySelectorAll`。组件库页签会把一段样式挂到 document 上,没有这些会在渲染时抛错。产品页面没有为测试改行为。
- 可拖分栏的名字在首页脚本里仍出现 1 次,和上一单记下的次数一样。库文件名 `react-resizable-panels` 在首页脚本里是 0。星盘页的分栏是打开星盘后才载入。
## 环境缺口
- 没有登录态。没有在浏览器里打开过星盘页。1024、1280、390 三种宽度的截图没有做,不写成通过。可照做的步骤在 `docs/testing/ui-kit-migration-20261023.md` 的「星盘页」一节。
+12
View File
@@ -29,3 +29,15 @@
3. 回答下面可以赞、踩、复制。点踩之后可以选原因,也可以什么都不选就提交。兑换窗的句子和以前一样。
4. 桌面上,点数右边有一个打开本命盘的按钮。打开后是当前人物的本命盘。拖右缘,宽度停在大约 280 到 560 像素。收起后再打开,宽度还在。刷新页面,开着和宽度都还在。
5. 把窗口改成大约 375 像素宽。顶栏没有打开本命盘的按钮。
## 星盘页
没有登录态,下面各项都还没在浏览器里看过。1024、1280 和 390 三种宽度的截图也没做。
1. 打开星盘。四个页签是星盘、基础信息、大运、西洋盘。当前项有下划线。点基础信息、大运、西洋盘,内容跟着换。盘还没出来时,这四个不能点。
2. 在星盘页里,本命、D9、月亮这些盘型是一排。选中的一格是亮的。盘型多时可以横着滑,页面本身不横滚。点右边「全部盘型」,从底部打开,能看到 23 种。点一种,抽屉关上,条上滚到那一格。
3. 把窗口放到大约 1280 或 1024 宽。盘和旁边的参数中间有一条可以拖。拖宽之后行星表仍然横跨整行,没有缩回左半边。把窗口改成大约 390 宽,盘在上,参数在下,页面没有横向滚动条。
4. 看本命行星表的状态列。有字的都是同一种小标记。落陷,以及写着「敌」的,颜色更重。没有状态的格子是「—」,上升那一格也是「—」。指上去能读到「上升点是命盘的起点,不是行星,不判断入旺或落陷」。
5. 点顶栏的日期和地点。可以改出生资料。点框里面的空白不会把没保存的修改丢掉。按 Esc 或点外面会关上。
6. 在本命和 D9 之间来回切。星体从原来的宫位移到新的宫位,停稳后和直接打开那张盘一样。换成另一个人时,格子重画,星体不飞。系统开了减少动态效果时,只淡入,没有位移。第一次打开星盘时的那一次画线,不会因为换盘再来一遍。
7. 点开一段收着的大运,下面的小运展开。0 年的那一段不能展开。右栏先是 Chara。点 Yogini 才去取另一套。这一套失败时,再点一次 Yogini 会再要一次。
+13 -11
View File
@@ -521,7 +521,7 @@ The birth-time rectification session is the consultation transcript plus a house
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). A click on the dialog's padding, or releasing a text selection there, does not close the dialog or drop unsaved edits. Escape still closes it, and so does a click on the backdrop outside the dialog box. Below 768px that row wraps onto a second line and the chart header grows to fit it.
- The chart page birth line is a button (`.chart-birth-line`) after the person: `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it.
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit control (`.credit-balance`) is a normal outline button, 44px, with a coins icon and the number; 点 stays in the accessible name. The rectification 「当前盘面」 chip stays 40px. The shared `::before` hit area stays inset -2px. The onboarding checklist trigger stays 32px.
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
@@ -1178,13 +1178,13 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。
四个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。一排等重的药丸按钮会读成几个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
四个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。一排等重的药丸按钮会读成几个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。2026-10-25 起这四个页签是组件库 `Tabs`,`variant="line"`。盘还没到时四个都不可选。
### 盘型条与「星盘类型」面板(2026-09-28,TASK-chart-types D2)
印度「星盘」Tab 内部是一条**盘型条**,共 23 种,顺序固定:本命、D9、月亮盘、Bhava、行运、D2–D8、D10、D11、D12、D16、D20、D24、D27、D30、D40、D45、D60。条上文字短:「本命 / D9 / 月亮 / Bhava / 行运 / D2 …」。条用既有 chip 的样子(比 Tab **低一级**:无描边、无卡面、caption 字号、三级文字色,当前项只有一块浅底),`flex-wrap: nowrap` 且自身横向滚动(`.chart-page-type-strip`),375px 下一行、页面不横滚。西洋盘不做成盘型。
印度「星盘」Tab 内部是一条**盘型条**,共 23 种,顺序固定:本命、D9、月亮盘、Bhava、行运、D2–D8、D10、D11、D12、D16、D20、D24、D27、D30、D40、D45、D60。条上文字短:「本命 / D9 / 月亮 / Bhava / 行运 / D2 …」。2026-10-25 起这条是分段式 `Tabs`(灰底槽,选中的一格浮起亮底),放在横向 `ScrollArea` 里(`.chart-page-type-strip` 仍 `flex-wrap: nowrap`),375px 下一行、页面不横滚。不用描边的分段开关。西洋盘不做成盘型。
条尾固定一个「≡」(`.chart-page-type-menu`,44×44,`aria-label="全部盘型"`),在滚动容器外面,不会被滚走。点它打开原生 `<dialog>`「星盘类型」:标题 + 关闭,三列网格(`.chart-page-type-grid`),每格大字标签 + 一行小字用途(D 号盘的用途取引擎 `VargaType.meaning`,与 `vedic-chart-tables.json` 同源;本命「D1」、月亮盘「以月亮为第 1 宫」、Bhava「不等宫」、行运「今天」)。当前格主文字色描边 + 浅底。点任一格 = 选中、关面板、把条滚到该项。手机是底部抽屉,≥860px 是居中 720px,框与报告「分块导出」抽屉同规格。Esc、点遮罩关闭,焦点回到「≡」。Tab、chip、「≡」、格子触摸目标均 ≥ 44px(格子 56px)。
条尾固定一个菜单按钮(`.chart-page-type-menu`,44×44,`aria-label="全部盘型"`),在滚动容器外面,不会被滚走。点它打开底部「星盘类型」抽屉:标题 + 关闭,三列网格(`.chart-page-type-grid`),每格大字标签 + 一行小字用途(D 号盘的用途取引擎 `VargaType.meaning`,与 `vedic-chart-tables.json` 同源;本命「D1」、月亮盘「以月亮为第 1 宫」、Bhava「不等宫」、行运「今天」)。当前格主文字色描边 + 浅底。点任一格 = 选中、关面板、把条滚到该项。≥860px 抽屉变窄并四角圆角,仍从底部打开。Esc、点遮罩关闭。页签、盘型格、「全部盘型」、格子触摸目标均 ≥ 44px(格子 64px)。
D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句:「这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。」
@@ -1196,11 +1196,13 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
**不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。
桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。
桌面(≥1024)盘面与参数表左右并排,放在可拖分栏里,默认约 58% / 42%,最窄约 32% / 24%,宽度不记到本机。分栏在盘面这一块载入后再出现;更窄、或分栏还没到时,盘在上、参数在下。行星表仍是跨两列的整行——它的列宽需要完整版心。表格区由 `VedicLayout` 的整行容器承载,不靠表格自身类名跨栏。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。
**符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、盘型 chip 均 44px。
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。页签和盘型格均 44px。星盘的线、宫和符号仍是原来的手绘,不换成组件库的图。
**换盘和换人(2026-10-25)。** 同一人换盘型时,星体从旧宫位移到新宫位,约 0.44 秒,停在和新盘直接画出来相同的位置。换人时格线重画约 0.8 秒,星体不飞。减少动态效果时不做位移,格线只淡入。第一次打开那一次的画线、西洋盘和大运条进场仍只走一遍,换盘或换人不重播。
### 星盘档案与当前人物(2026-09-25修复)
@@ -1244,9 +1246,9 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| 星宿 | 引擎返回的星宿名。**没有中文宿名表就不造一个**:印度 27 宿与中国二十八宿不是同一套,硬对上去是编事实 |
| 宿主 | 星宿主的上色符号,居中 |
| pada | 1–4,居中 |
| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 |
| 状态 | 尊贵 / 庙旺等。有字的用同一种徽标:落陷、敌是强调色,其余是次要色。没有状态写「—」,不画徽标 |
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「/」(2026-10-21,推翻 2026-10-15 的「不适用」;与分盘表、报告同一口径)。这一格是 `chart-page-asc-status`,鼠标悬停、键盘聚焦或手机点一下,用盘面同一套气泡显示「上升点是命盘的起点,不是行星,不判断入旺或落陷」。读屏读到的是这一句。数字列 `tabular-nums`,符号列与宿主列居中。
第一行是上升。星宿 / 宿主 / pada 用引擎按上升经度算出的值,和九曜同一个算法(2026-10-03,BUG-1201;之前引擎漏算,这三格一直是「—」);旧缓存里没有这三项时仍写「—」,页面不自己推算。上升没有尊贵,状态格写「—」(2026-10-25,不再用「/」;2026-10-21 曾用「/」推翻「不适用」)。这一格是 `chart-page-asc-status`,鼠标悬停、键盘聚焦或手机点一下,用盘面同一套气泡显示「上升点是命盘的起点,不是行星,不判断入旺或落陷」。读屏读到的是这一句。本命和月亮这张八列表仍是原来的表格元素,方便和冻结的本命表对照;分盘、Bhava、行运、西洋表用组件库表格,列和跨满整行不变。分盘的庙旺、同位和 Bhava 的换宫仍是字,不套这枚徽标。数字列 `tabular-nums`,符号列与宿主列居中。
本轮不新增第二层页签:参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。
@@ -1254,8 +1256,8 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
| 盘型 | 列 | 说明 |
| --- | --- | --- |
| 本命 | 上面八列 | 不变(与基线渲染逐字节相同) |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格写「/」,悬停或点按同一句「上升点是命盘的起点,不是行星,不判断入旺或落陷」(2026-10-21,推翻 2026-10-15)。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示此列,其他分盘不显示。2026-10-13:同座写「是」,不同座写「否」,表头仍是「同位」(不再用横杠,避免看成没算)。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| 本命 | 上面八列 | 列不变。状态列改成同一种徽标,上升格改成「—」。冻结对照仍对同一份基线文件,只在断言里替换这两处 |
| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺是引擎给的十档(入旺 / 本垣 / 入庙 / 极友 / 入友 / 中性 / 入敌 / 极敌 / 落陷 / 落陷取消),措辞与本命「状态」列一字不差(2026-10-03,BUG-1202,取代原先「入旺 / 自宫 / 落陷,其余—」的三档);旧缓存没有引擎等级时整列不显示,页面不自算;上升此格写「—」,悬停或点按同一句「上升点是命盘的起点,不是行星,不判断入旺或落陷」(2026-10-25 起不再写「/」;2026-10-21 曾用「/」推翻 2026-10-15)。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示此列,其他分盘不显示。2026-10-13:同座写「是」,不同座写「否」,表头仍是「同位」(不再用横杠,避免看成没算)。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 |
| 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) |
| Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) |
| 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 |
@@ -1286,7 +1288,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。
列表用可折叠组件。只有当前段一开始是开的,这一次打开不播放展开动画。之后点开或收起才用动效:高度约 0.24 秒,减少动态效果时高度不动、只淡入。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。标题与子运行高 44px。未展开的子运仍留在页面里,用收起状态藏起来。0 年段不能展开,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。当前大运用浅底和细边框标出,不画左侧强调线。右栏已经选中的 Chara 或 Yogini 再点一次,会再请求这一栏。
### 西洋盘 Tab(2026-09-25)
@@ -12,11 +12,6 @@
"src/components/birth-time-intake.tsx",
"src/components/birth-time-legacy-rectification.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/chart-page/chart-birth-editor.tsx",
"src/components/chart-page/chart-dasha-tab.tsx",
"src/components/chart-page/chart-page-view.tsx",
"src/components/chart-page/chart-type-picker.tsx",
"src/components/chart-page/chart-vedic-tab.tsx",
"src/components/chart-profile-form.tsx",
"src/components/consultation-run-timeline.tsx",
"src/components/email-otp-login.tsx",
+53 -14
View File
@@ -5785,7 +5785,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
white-space: nowrap;
}
.chart-page-tab:hover { color: var(--color-ink); }
.chart-page-tab.is-current {
.chart-page-tab.is-current,
.chart-page-tab[data-active] {
border-bottom-color: var(--color-focus);
color: var(--color-ink);
}
@@ -5837,6 +5838,12 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
}
.chart-page-type-strip::-webkit-scrollbar { display: none; }
.chart-page-type-strip .chart-page-chip { flex: 0 0 auto; white-space: nowrap; }
.chart-page-type-strip [data-slot="tabs-trigger"] {
flex: 0 0 auto;
min-width: 44px;
min-height: 44px;
white-space: nowrap;
}
.chart-page-type-menu {
display: inline-grid;
flex: 0 0 auto;
@@ -5854,16 +5861,14 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.chart-page-type-cell:focus-visible,
.chart-page-type-sheet-close:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.chart-page-type-menu svg { width: 20px; height: 20px; }
/* The sheet: bottom drawer on phones, centred 720px dialog from 860px — the
same frame as the report's 分块导出 drawer. */
/* Bottom sheet. From 860px it is narrower, with a full radius. Position stays with the sheet. */
.chart-page-type-sheet {
position: fixed;
inset: auto 0 0 0;
width: min(760px, 100%);
max-width: none;
max-height: 80vh;
margin: 0 auto;
margin-inline: auto;
padding: 0;
gap: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--color-canvas);
@@ -5940,10 +5945,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.chart-page-type-cell.is-current { border-color: var(--color-ink); background: var(--color-selected); }
@media (min-width: 860px) {
.chart-page-type-sheet {
inset: 0;
width: min(520px, 92vw);
max-height: 80vh;
margin: auto;
border-radius: var(--radius-lg);
}
}
@@ -6319,17 +6322,23 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
margin: 0;
font-size: var(--type-body-sm);
}
.chart-page-dasha-period summary {
.chart-page-dasha-summary {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
list-style: none;
font: inherit;
font-size: var(--type-body-sm);
text-align: left;
}
.chart-page-dasha-period summary::-webkit-details-marker { display: none; }
.chart-page-dasha-period summary::before {
.chart-page-dasha-summary::before {
content: "";
width: 0.45em;
height: 0.45em;
@@ -6338,8 +6347,8 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
border-bottom: 1.5px solid var(--color-ink-tertiary);
transform: rotate(-45deg);
}
.chart-page-dasha-period details[open] > summary::before { transform: rotate(45deg); }
.chart-page-dasha-period details:not([open]) > ul { display: none; }
.chart-page-dasha-period [data-open] > .chart-page-dasha-summary::before { transform: rotate(45deg); }
.chart-page-dasha-period [data-slot="collapsible-content"][data-closed] ul { display: none; }
.chart-page-dasha-summary-label { min-width: 0; }
.chart-page-dasha-dates {
margin-left: auto;
@@ -6487,10 +6496,40 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
/* Chart and parameters sit side by side. The third grid item is the table
row, so a wrapper around a table cannot drop it back into the left column. */
.chart-page-vedic-tables { grid-column: 1 / -1; }
.chart-page-vedic-split { grid-column: 1 / -1; min-width: 0; }
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@keyframes chart-grid-redraw {
from { stroke-dashoffset: 48; }
to { stroke-dashoffset: 0; }
}
@keyframes chart-grid-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.chart-page-vedic-stage.is-redrawing .personal-report-chart-cell {
stroke-dasharray: 8 6;
animation: chart-grid-redraw 0.8s ease-out;
}
.chart-page-western-wheel.is-redrawing .chart-page-western-ring,
.chart-page-western-wheel.is-redrawing .chart-page-western-spoke,
.chart-page-western-wheel.is-redrawing .chart-page-western-house {
stroke-dasharray: 8 6;
animation: chart-grid-redraw 0.8s ease-out;
}
.chart-page-vedic-stage.is-fading .chart-page-vedic-plot,
.chart-page-western-wheel.is-fading {
animation: chart-grid-fade 0.15s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.chart-page-vedic-stage.is-redrawing .personal-report-chart-cell,
.chart-page-western-wheel.is-redrawing .chart-page-western-ring,
.chart-page-western-wheel.is-redrawing .chart-page-western-spoke,
.chart-page-western-wheel.is-redrawing .chart-page-western-house {
animation: none;
stroke-dasharray: none;
}
.agent-activity-status__text {
animation: none;
background: none;
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { OrbitWaiting } from "@/components/orbit-waiting";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useSessionList } from "@/hooks/use-session-list";
import {
invalidateSubjectCatalog,
@@ -108,7 +110,6 @@ export function ChartBirthEditor({
readonly onClose: () => void;
readonly onSaved?: () => Promise<void> | void;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const { account, setAccount } = useSessionList();
const [library, setLibrary] = useState<ChartLibraryRecord[] | null>(null);
const [failed, setFailed] = useState(false);
@@ -117,15 +118,6 @@ export function ChartBirthEditor({
const [saving, setSaving] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
dialog.showModal();
return () => {
dialog.close();
};
}, []);
useEffect(() => {
let cancelled = false;
const refresh = () => {
@@ -185,45 +177,35 @@ export function ChartBirthEditor({
}
}
const title = record?.role === "other" ? "编辑人物" : "编辑我的资料";
return (
<dialog
ref={dialogRef}
className="chart-birth-dialog"
aria-label={record?.role === "other" ? "编辑人物" : "编辑我的资料"}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (chartBirthDialogShouldClose(
event.target,
event.currentTarget,
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
)) onClose();
}}
>
{failed ? (
<p>
人物列表暂时读不出来
<button type="button" onClick={() => { setFailed(false); setLibrary(null); setAttempt((current) => current + 1); }}>重试</button>
</p>
) : library === null ? (
<OrbitWaiting label="人物资料还在读" />
) : !record || !draft || !baseline ? (
<p>这份资料暂时读不出来</p>
) : (
<ChartBirthEditorForm
role={record.role}
baseline={baseline}
draft={draft}
saving={saving}
onChange={setDraft}
onCancel={onClose}
onSubmit={(event) => void save(event)}
/>
)}
</dialog>
<Dialog open onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent
className="chart-birth-dialog w-[min(560px,calc(100vw-32px))] max-w-[min(560px,calc(100vw-32px))] sm:max-w-[min(560px,calc(100vw-32px))]"
showCloseButton={false}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
{failed ? (
<p>
人物列表暂时读不出来
<Button type="button" variant="outline" onClick={() => { setFailed(false); setLibrary(null); setAttempt((current) => current + 1); }}>重试</Button>
</p>
) : library === null ? (
<OrbitWaiting label="人物资料还在读" />
) : !record || !draft || !baseline ? (
<p>这份资料暂时读不出来</p>
) : (
<ChartBirthEditorForm
role={record.role}
baseline={baseline}
draft={draft}
saving={saving}
onChange={setDraft}
onCancel={onClose}
onSubmit={(event) => void save(event)}
/>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,16 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState, useSyncExternalStore, type ReactNode } from "react";
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { OrbitWaiting } from "@/components/orbit-waiting";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import { loadMotionReact, type MotionReact } from "@/lib/motion/load-react";
import { motionPresets } from "@/lib/motion/presets";
type DashaTrack = ChartViewOk["dasha"]["vimshottari"];
type DashaPeriod = DashaTrack["periods"][number];
@@ -95,6 +99,94 @@ function Timeline({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: st
);
}
let dashaMotion: MotionReact | null = null;
let dashaMotionStarted = false;
const dashaMotionListeners = new Set<() => void>();
function subscribeDashaMotion(listener: () => void) {
dashaMotionListeners.add(listener);
if (!dashaMotionStarted) {
dashaMotionStarted = true;
void loadMotionReact().then((mod) => {
dashaMotion = mod;
for (const item of dashaMotionListeners) item();
}).catch(() => {
dashaMotionStarted = false;
});
}
return () => dashaMotionListeners.delete(listener);
}
function useDashaMotion(enabled: boolean): MotionReact | null {
return useSyncExternalStore(
(listener) => (enabled ? subscribeDashaMotion(listener) : () => {}),
() => (enabled ? dashaMotion : null),
() => null,
);
}
function DashaAntarList({ period }: { period: DashaPeriod }) {
if (period.antardashas.length === 0) return null;
return (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "chart-page-dasha-antar is-current" : "chart-page-dasha-antar"}>
<span>{antar.label} ·</span>
<span className="chart-page-dasha-dates">{formatYearMonth(antar.start)} – {formatYearMonth(antar.end)}</span>
</li>
))}
</ul>
);
}
function DashaPeriodRow({ period }: { period: DashaPeriod }) {
const [open, setOpen] = useState(Boolean(period.current));
const [animate, setAnimate] = useState(false);
const motion = useDashaMotion(animate);
const list = <DashaAntarList period={period} />;
const reduced = typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let body: ReactNode = list;
if (animate && motion && list) {
const duration = reduced ? 0 : motionPresets.disclosure.duration;
const fade = reduced ? motionPresets.disclosure.reduced.duration : motionPresets.disclosure.duration;
const Panel = motion.motion.div;
body = (
<Panel
initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduced ? { opacity: 1 } : { height: "auto", opacity: 1 }}
transition={{
height: { duration, ease: [...motionPresets.disclosure.ease] },
opacity: { duration: fade },
}}
style={{ overflow: "hidden" }}
>
{list}
</Panel>
);
}
return (
<li className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}>
<Collapsible
open={open}
onOpenChange={(next) => {
setAnimate(dashaMotion != null);
setOpen(next);
}}
>
<CollapsibleTrigger className="chart-page-dasha-summary">
<span className="chart-page-dasha-summary-label">{period.label}</span>
<span className="chart-page-dasha-dates">{formatYearMonth(period.start)} – {formatYearMonth(period.end)}</span>
</CollapsibleTrigger>
<CollapsibleContent keepMounted className="chart-page-dasha-panel">
{body}
</CollapsibleContent>
</Collapsible>
</li>
);
}
function Track({
title,
method,
@@ -123,27 +215,7 @@ function Track({
<p className="chart-page-note">{CHARA_ZERO_YEAR_NOTE}</p>
</li>
) : (
<li
key={`${period.lord}-${period.start}-${index}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<details open={period.current || undefined}>
<summary>
<span className="chart-page-dasha-summary-label">{period.label}</span>
<span className="chart-page-dasha-dates">{formatYearMonth(period.start)} – {formatYearMonth(period.end)}</span>
</summary>
{period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "chart-page-dasha-antar is-current" : "chart-page-dasha-antar"}>
<span>{antar.label} ·</span>
<span className="chart-page-dasha-dates">{formatYearMonth(antar.start)} – {formatYearMonth(antar.end)}</span>
</li>
))}
</ul>
) : null}
</details>
</li>
<DashaPeriodRow key={`${period.lord}-${period.start}-${index}`} period={period} />
)
))}
</ol>
@@ -169,6 +241,7 @@ export function ChartDashaTab({
}) {
const [uncontrolledSystem, setUncontrolledSystem] = useState<DashaSystemId>(initialSystem);
const system = controlledSystem ?? uncontrolledSystem;
useDashaMotion(true);
const asOf = view.dasha.asOf;
const pending = pendingLayers?.has(system) ?? false;
const failure = layerFailures?.get(system);
@@ -180,19 +253,30 @@ export function ChartDashaTab({
}
const switcher = (
<div className="chart-page-dasha-switch" role="tablist" aria-label="大运体系">
{DASHA_SYSTEMS.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={system === item.id}
className={system === item.id ? "chart-page-tab is-current" : "chart-page-tab"}
onClick={() => selectSystem(item.id)}
>
{item.label}
</button>
))}
<div className="chart-page-dasha-switch">
<Tabs
className="w-full"
value={system}
onValueChange={(value) => {
if (value === "chara" || value === "yogini") selectSystem(value);
}}
>
<TabsList variant="line" aria-label="大运体系" className="w-full">
{DASHA_SYSTEMS.map((item) => (
<TabsTrigger
key={item.id}
value={item.id}
className="chart-page-tab"
onClick={() => {
// The active tab does not emit onValueChange. Clicking it again retries that layer.
if (item.id === system) selectSystem(item.id);
}}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
@@ -0,0 +1,35 @@
export type GlyphPoint = { x: number; y: number };
export type GlyphMove = {
key: string;
from: GlyphPoint;
to: GlyphPoint;
};
export function glyphMotionKey(kind: "glyph" | "asc", text: string): string {
return `${kind}:${text}`;
}
/**
* End points are always the next render. Travel is only for a chart-type
* change with a previous map. The first paint and reduced motion do not move.
*/
export function planGlyphMotion(
previous: Readonly<Record<string, GlyphPoint>> | null,
next: Readonly<Record<string, GlyphPoint>>,
options: { firstPaint: boolean; reduced: boolean; chartTypeChanged: boolean },
): { moves: GlyphMove[]; fadeOnly: boolean } {
if (options.firstPaint || !options.chartTypeChanged) {
return { moves: [], fadeOnly: false };
}
if (options.reduced || previous == null) {
return { moves: [], fadeOnly: true };
}
const moves: GlyphMove[] = [];
for (const [key, to] of Object.entries(next)) {
const from = previous[key];
if (!from || (from.x === to.x && from.y === to.y)) continue;
moves.push({ key, from, to });
}
return { moves, fadeOnly: false };
}
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import { OnboardingDot } from "@/components/onboarding-dot";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor";
@@ -142,15 +144,16 @@ export function ChartPageView({
const birthline = view != null && view.status === "ok"
? (
<button
<Button
type="button"
className="chart-birth-line"
variant="ghost"
className="chart-birth-line h-auto rounded-none border-0 bg-transparent px-0 font-normal shadow-none"
aria-label={`编辑出生资料,${view.profile.date} ${view.profile.time},${view.profile.placeLabel}`}
onClick={() => setEditingBirth(true)}
>
<span>{`${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`}</span>
<Pencil aria-hidden="true" />
</button>
</Button>
)
: null;
@@ -190,22 +193,27 @@ export function ChartPageView({
</section>
) : (
<div className="chart-page-body">
<div className="chart-page-tabs" role="tablist" aria-label="星盘体系">
{CHART_VIEW_TABS.map((item) => (
<button
key={item.id}
type="button"
role="tab"
disabled={view == null}
aria-selected={tab === item.id}
className={tab === item.id ? "chart-page-tab is-current" : "chart-page-tab"}
onClick={() => selectTab(item.id)}
>
{item.label}
{journey?.tabHasDot(item.id) ? <OnboardingDot playKey={`tab-${item.id}`} /> : null}
</button>
))}
</div>
<Tabs
className="chart-page-tabs"
value={tab}
onValueChange={(value) => {
if (value) selectTab(value as ChartViewTabId);
}}
>
<TabsList variant="line" aria-label="星盘体系" className="w-full justify-start">
{CHART_VIEW_TABS.map((item) => (
<TabsTrigger
key={item.id}
value={item.id}
disabled={view == null}
className="chart-page-tab"
>
{item.label}
{journey?.tabHasDot(item.id) ? <OnboardingDot playKey={`tab-${item.id}`} /> : null}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<section className="chart-page-panel" role="tabpanel" data-onboarding-anchor="chart-tab">
{view == null ? <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingChart} /> : <>
{tab === "vedic" ? (
@@ -1,8 +1,18 @@
"use client";
import { Menu, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CHART_TYPE_ORDER,
CHART_TYPE_SHEET_COPY,
@@ -18,120 +28,106 @@ function sheetCopy(id: ChartTypeId): { label: string; use: string | null } {
return { label: id, use: vargaMeaning(Number(id.slice(1))) };
}
/**
* The 「星盘类型」 sheet: every chart type in a three-column grid. A native
* `<dialog>` so Esc, the backdrop and the focus trap come from the platform;
* focus returns to the 「≡」 button that opened it.
*/
function ChartTypeSheet({ current, onSelect, onClose }: {
export function ChartTypeSheetBody({ current, onSelect }: {
current: string;
onSelect: (id: ChartTypeId) => void;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
const previous = document.activeElement;
element?.showModal();
return () => {
element?.close();
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
};
}, []);
return (
<dialog
ref={dialog}
className="chart-page-type-sheet"
aria-labelledby="chart-page-type-sheet-title"
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="chart-page-type-sheet-panel">
<header className="chart-page-type-sheet-header">
<h2 id="chart-page-type-sheet-title">{CHART_VIEW_COPY.chartTypeSheetTitle}</h2>
<button type="button" className="chart-page-type-sheet-close" aria-label={CHART_VIEW_COPY.chartTypeSheetClose} onClick={onClose}>
<X aria-hidden="true" />
</button>
</header>
<div className="chart-page-type-grid">
{CHART_TYPE_ORDER.map((id) => {
const copy = sheetCopy(id);
return (
<button
key={id}
type="button"
aria-pressed={id === current}
className={id === current ? "chart-page-type-cell is-current" : "chart-page-type-cell"}
onClick={() => onSelect(id)}
>
<strong>{copy.label}</strong>
{copy.use ? <small>{copy.use}</small> : null}
</button>
);
})}
</div>
</div>
</dialog>
<div className="chart-page-type-grid">
{CHART_TYPE_ORDER.map((id) => {
const copy = sheetCopy(id);
return (
<Button
key={id}
type="button"
variant="outline"
aria-pressed={id === current}
className={id === current ? "chart-page-type-cell is-current h-auto" : "chart-page-type-cell h-auto"}
onClick={() => onSelect(id)}
>
<strong>{copy.label}</strong>
{copy.use ? <small>{copy.use}</small> : null}
</Button>
);
})}
</div>
);
}
/**
* Chart-type strip + 「≡」. The strip scrolls sideways and never wraps; the
* button stays pinned at the row's end and opens the full sheet.
* Chart-type strip + 「全部盘型」. The strip is a segmented tab list inside a
* sideways scroller; the button stays pinned and opens the full sheet.
*/
export function ChartTypePicker({ current, onSelect }: {
current: string;
onSelect: (id: ChartTypeId) => void;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const buttons = useRef(new Map<ChartTypeId, HTMLButtonElement>());
const triggers = useRef(new Map<ChartTypeId, HTMLElement>());
function chooseFromSheet(id: ChartTypeId) {
setSheetOpen(false);
onSelect(id);
// A choice made in the sheet may sit far along the strip: bring it into view.
buttons.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
triggers.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
return (
<div className="chart-page-type-bar">
<div className="chart-page-type-strip" role="tablist" aria-label="盘型">
{CHART_TYPE_ORDER.map((id) => (
<button
key={id}
type="button"
role="tab"
ref={(node) => {
if (node) buttons.current.set(id, node);
else buttons.current.delete(id);
}}
aria-selected={id === current}
className={id === current ? "chart-page-chip is-current" : "chart-page-chip"}
onClick={() => onSelect(id)}
>
{CHART_TYPE_STRIP_LABEL[id] ?? id}
</button>
))}
</div>
<button
type="button"
className="chart-page-type-menu"
aria-label={CHART_VIEW_COPY.chartTypeMenu}
aria-haspopup="dialog"
aria-expanded={sheetOpen}
onClick={() => setSheetOpen(true)}
>
<Menu aria-hidden="true" />
</button>
{sheetOpen ? (
<ChartTypeSheet
current={current}
onClose={() => setSheetOpen(false)}
onSelect={chooseFromSheet}
/>
) : null}
<ScrollArea className="chart-page-type-strip">
<Tabs
value={current}
onValueChange={(value) => {
if (value) onSelect(value as ChartTypeId);
}}
>
<TabsList variant="default" aria-label="盘型" className="w-max flex-nowrap">
{CHART_TYPE_ORDER.map((id) => (
<TabsTrigger
key={id}
value={id}
ref={(node) => {
const element = node instanceof HTMLElement ? node : null;
if (element) triggers.current.set(id, element);
else triggers.current.delete(id);
}}
>
{CHART_TYPE_STRIP_LABEL[id] ?? id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</ScrollArea>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<Button
type="button"
variant="ghost"
size="icon"
className="chart-page-type-menu"
aria-label={CHART_VIEW_COPY.chartTypeMenu}
aria-haspopup="dialog"
aria-expanded={sheetOpen}
onClick={() => setSheetOpen(true)}
>
<Menu aria-hidden="true" />
</Button>
<SheetContent
side="bottom"
showCloseButton={false}
className="chart-page-type-sheet"
aria-labelledby="chart-page-type-sheet-title"
>
<SheetHeader className="chart-page-type-sheet-header">
<SheetTitle id="chart-page-type-sheet-title">{CHART_VIEW_COPY.chartTypeSheetTitle}</SheetTitle>
<SheetClose
aria-label={CHART_VIEW_COPY.chartTypeSheetClose}
className="chart-page-type-sheet-close"
>
<X aria-hidden="true" />
</SheetClose>
</SheetHeader>
<ChartTypeSheetBody current={current} onSelect={chooseFromSheet} />
</SheetContent>
</Sheet>
</div>
);
}
@@ -1,8 +1,10 @@
"use client";
import type { ReactNode } from "react";
import { useLayoutEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import { Badge } from "@/components/ui/badge";
import { Table } from "@/components/ui/table";
import type {
ChartViewFailureStatus,
ChartViewAscendantRow,
@@ -31,6 +33,9 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import { VARGA_DIGNITY_LABEL } from "@/lib/chart-view-varga";
import { ChartPageSkeleton } from "./chart-page-skeleton";
import { ChartSymbolHelp } from "./chart-symbol-help";
import { glyphMotionKey, planGlyphMotion, type GlyphPoint } from "./chart-glyph-motion";
import { loadMotionReact } from "@/lib/motion/load-react";
import { motionPresets } from "@/lib/motion/presets";
/** The same coloured symbol the chart draws, so a row and a house read as one body. */
function BodyGlyph({ name }: { name: string }) {
@@ -72,7 +77,7 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
<td>{row.nakshatra}</td>
<td className="chart-page-planet-mid"><BodyGlyph name={row.nakshatraLord} /></td>
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada}</td>
<td>{row.status}</td>
<td><StatusBadge status={row.status} /></td>
</tr>
);
}
@@ -80,24 +85,24 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
/** The ascendant is the chart's starting point, not a planet, so it has no dignity. */
const ASCENDANT_STATUS_HELP = "上升点是命盘的起点,不是行星,不判断入旺或落陷";
function StatusBadge({ status }: { status: string }) {
const text = status.trim();
if (!text || text === "—" || text === "/") return "—";
const variant = text.includes("落陷") || text.includes("敌") ? "destructive" : "secondary";
return <Badge variant={variant}>{text}</Badge>;
}
function AscendantStatusCell({ className }: { className?: string }) {
return (
<td className={className ? `${className} chart-page-asc-status` : "chart-page-asc-status"}>
<button
type="button"
className="chart-page-asc-status-mark"
data-chart-help={ASCENDANT_STATUS_HELP}
aria-label={ASCENDANT_STATUS_HELP}
>
<span aria-hidden="true">/</span>
</button>
<span data-chart-help={ASCENDANT_STATUS_HELP} aria-label={ASCENDANT_STATUS_HELP}>—</span>
</td>
);
}
/**
* The ascendant is a chart point: nakshatra, lord and pada come from the
* engine (BUG-1201). It is not a planet, so the status cell is "/" and
* engine (BUG-1201). It is not a planet, so the status cell is "—" and
* ASCENDANT_STATUS_HELP. `house` is 1 except on the Moon chart, which counts
* from the Moon.
*/
@@ -206,7 +211,7 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
return (
<ChartSymbolHelp resetKey={`${varga.id}:${columns.supportsDignity}:${columns.supportsVargottama}`}>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<Table className="chart-page-planet-table">
<caption>{varga.id} · {varga.meaning}</caption>
<thead>
<tr>
@@ -222,7 +227,7 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) {
{varga.ascendant ? <VargaAscendantRow row={varga.ascendant} {...columns} /> : null}
{(varga.rows ?? []).map((row) => <VargaRow key={row.name} row={row} {...columns} />)}
</tbody>
</table>
</Table>
</div>
</ChartSymbolHelp>
);
@@ -236,7 +241,7 @@ function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) {
function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
return (
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<Table className="chart-page-planet-table">
<caption>行运 · {transit.date} {transit.time}({transit.timezoneLabel})</caption>
<thead>
<tr>
@@ -263,7 +268,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
);
})}
</tbody>
</table>
</Table>
</div>
);
}
@@ -272,7 +277,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
return (
<>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<Table className="chart-page-planet-table">
<caption>Bhava · {bhava.houseSystem}</caption>
<thead>
<tr>
@@ -299,10 +304,10 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
);
})}
</tbody>
</table>
</Table>
</div>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<Table className="chart-page-planet-table">
<caption>宫头与宫界</caption>
<thead>
<tr>
@@ -320,12 +325,180 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
</tr>
))}
</tbody>
</table>
</Table>
</div>
</>
);
}
type ResizableModule = typeof import("@/components/ui/resizable");
let chartPanels: ResizableModule | null = null;
let chartPanelsStarted = false;
const chartPanelListeners = new Set<() => void>();
function notifyChartPanels() {
for (const listener of chartPanelListeners) listener();
}
function subscribeChartPanels(listener: () => void) {
chartPanelListeners.add(listener);
if (!chartPanelsStarted) {
chartPanelsStarted = true;
void import("@/components/ui/resizable").then((mod) => {
chartPanels = mod;
notifyChartPanels();
}).catch(() => {
chartPanelsStarted = false;
});
}
return () => chartPanelListeners.delete(listener);
}
function useChartPanels(enabled: boolean): ResizableModule | null {
return useSyncExternalStore(
(listener) => (enabled ? subscribeChartPanels(listener) : () => {}),
() => (enabled ? chartPanels : null),
() => null,
);
}
function desktopChartSplitSnapshot(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(min-width: 1024px)").matches;
}
function subscribeDesktopChartSplit(listener: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const query = window.matchMedia("(min-width: 1024px)");
query.addEventListener?.("change", listener);
return () => query.removeEventListener?.("change", listener);
}
function useDesktopChartSplit(): boolean {
return useSyncExternalStore(subscribeDesktopChartSplit, desktopChartSplitSnapshot, () => false);
}
function prefersReducedMotion(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function VedicChartSplit({ stage, params }: { stage: ReactNode; params: ReactNode }) {
const desktop = useDesktopChartSplit();
const Resizable = useChartPanels(desktop);
if (!desktop || !Resizable) return <>{stage}{params}</>;
return (
<Resizable.ResizablePanelGroup orientation="horizontal" className="chart-page-vedic-split">
<Resizable.ResizablePanel id="chart-plot" defaultSize="58%" minSize="32%">
{stage}
</Resizable.ResizablePanel>
<Resizable.ResizableHandle withHandle />
<Resizable.ResizablePanel id="chart-params" defaultSize="42%" minSize="24%">
{params}
</Resizable.ResizablePanel>
</Resizable.ResizablePanelGroup>
);
}
type GlyphNode = Element & {
classList: { contains(name: string): boolean };
textContent: string | null;
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
nextElementSibling: GlyphNode | null;
};
function readGlyphPoints(root: ParentNode): Record<string, GlyphPoint> {
const nodes = root.querySelectorAll?.(".personal-report-chart-glyph, .personal-report-chart-asc");
if (!nodes || typeof nodes.forEach !== "function") return {};
const next: Record<string, GlyphPoint> = {};
nodes.forEach((node) => {
const element = node as GlyphNode;
const kind = element.classList?.contains("personal-report-chart-asc") ? "asc" : "glyph";
const x = Number(element.getAttribute("x"));
const y = Number(element.getAttribute("y"));
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
next[glyphMotionKey(kind, element.textContent ?? "")] = { x, y };
});
return next;
}
function playGlyphMoves(root: ParentNode, moves: ReadonlyArray<{ key: string; from: GlyphPoint; to: GlyphPoint }>, ticket: { current: number }, generation: number) {
void loadMotionReact().then((mod) => {
if (ticket.current !== generation) return;
const nodes = root.querySelectorAll?.(".personal-report-chart-glyph, .personal-report-chart-asc");
if (!nodes) return;
const byKey = new Map<string, GlyphNode>();
nodes.forEach((node) => {
const element = node as GlyphNode;
const kind = element.classList?.contains("personal-report-chart-asc") ? "asc" : "glyph";
byKey.set(glyphMotionKey(kind, element.textContent ?? ""), element);
});
const duration = motionPresets.chartType.duration;
const ease = motionPresets.chartType.ease;
for (const move of moves) {
const node = byKey.get(move.key);
if (!node) continue;
node.setAttribute("x", String(move.from.x));
node.setAttribute("y", String(move.from.y));
const bar = node.nextElementSibling;
const barStart = bar?.classList?.contains("personal-report-chart-retrograde")
? {
x1: Number(bar.getAttribute("x1")),
x2: Number(bar.getAttribute("x2")),
y1: Number(bar.getAttribute("y1")),
y2: Number(bar.getAttribute("y2")),
}
: null;
const finish = () => {
if (ticket.current !== generation) return;
node.setAttribute("x", String(move.to.x));
node.setAttribute("y", String(move.to.y));
if (bar && barStart && Number.isFinite(barStart.x1)) {
const dx = move.to.x - move.from.x;
const dy = move.to.y - move.from.y;
bar.setAttribute("x1", String(barStart.x1 + dx));
bar.setAttribute("x2", String(barStart.x2 + dx));
bar.setAttribute("y1", String(barStart.y1 + dy));
bar.setAttribute("y2", String(barStart.y2 + dy));
}
};
try {
const xRun = mod.animate(move.from.x, move.to.x, {
duration,
ease: [...ease],
onUpdate: (value) => {
if (ticket.current !== generation) return;
node.setAttribute("x", String(value));
if (bar && barStart && Number.isFinite(barStart.x1)) {
bar.setAttribute("x1", String(barStart.x1 + (value - move.from.x)));
bar.setAttribute("x2", String(barStart.x2 + (value - move.from.x)));
}
},
});
const yRun = mod.animate(move.from.y, move.to.y, {
duration,
ease: [...ease],
onUpdate: (value) => {
if (ticket.current !== generation) return;
node.setAttribute("y", String(value));
if (bar && barStart && Number.isFinite(barStart.y1)) {
bar.setAttribute("y1", String(barStart.y1 + (value - move.from.y)));
bar.setAttribute("y2", String(barStart.y2 + (value - move.from.y)));
}
},
});
void Promise.all([xRun.finished, yRun.finished]).then(finish, finish);
} catch {
finish();
}
}
}).catch(() => {});
}
/** Chart, parameter table and boundary line, then the chart type's own table. */
function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
view: ChartViewOk;
@@ -334,22 +507,58 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
ascendantHouse?: number;
children: ReactNode;
}) {
const stageRef = useRef<HTMLDivElement>(null);
const positions = useRef<Record<string, GlyphPoint> | null>(null);
const seenType = useRef<string | null>(null);
const seenPerson = useRef<string | null>(null);
const generation = useRef(0);
const personKey = `${view.subjectId ?? ""}|${view.profileFingerprint ?? ""}`;
useLayoutEffect(() => {
const root = stageRef.current;
if (!root) return;
const next = readGlyphPoints(root);
if (Object.keys(next).length === 0) return;
const ticket = ++generation.current;
const firstPaint = seenType.current == null;
const personChanged = seenPerson.current != null && seenPerson.current !== personKey;
const typeChanged = !firstPaint && !personChanged && seenType.current !== ariaLabel;
const reduced = prefersReducedMotion();
const plan = planGlyphMotion(positions.current, next, {
firstPaint,
reduced,
chartTypeChanged: typeChanged,
});
root.classList?.remove("is-redrawing", "is-fading");
if (personChanged) root.classList?.add(reduced ? "is-fading" : "is-redrawing");
else if (plan.fadeOnly) root.classList?.add("is-fading");
if (plan.moves.length > 0) playGlyphMoves(root, plan.moves, generation, ticket);
positions.current = next;
seenType.current = ariaLabel;
seenPerson.current = personKey;
}, [ariaLabel, chart, personKey]);
const stage = (
<div className="chart-page-vedic-stage" ref={stageRef}>
<ChartSymbolHelp resetKey={JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, ariaLabel, ascendantHouse, chart])}>
<div className="chart-page-vedic-plot" data-onboarding-draw="vedic">
<VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} interactive />
</div>
</ChartSymbolHelp>
</div>
);
const params = (
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim — it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
);
return (
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<ChartSymbolHelp resetKey={JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, ariaLabel, ascendantHouse, chart])}>
<div className="chart-page-vedic-plot" data-onboarding-draw="vedic">
<VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} interactive />
</div>
</ChartSymbolHelp>
</div>
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim — it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
<VedicChartSplit stage={stage} params={params} />
<div className="chart-page-vedic-tables">
{children}
</div>
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { useLayoutEffect, useRef, useState, type MutableRefObject } from "react";
import { Table } from "@/components/ui/table";
import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import { formatDegreeMinutes } from "./chart-format";
@@ -51,13 +52,30 @@ export function ChartWesternTab({
view: ChartViewOk;
initialSelectedId?: string | null;
}) {
const personRef = useRef<string | null>(null);
const resetKey = JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, view.western]);
return <WesternTabSession key={resetKey} view={view} initialSelectedId={initialSelectedId} />;
return <WesternTabSession key={resetKey} view={view} initialSelectedId={initialSelectedId} personRef={personRef} />;
}
function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; initialSelectedId: string | null }) {
function WesternTabSession({ view, initialSelectedId, personRef }: {
view: ChartViewOk;
initialSelectedId: string | null;
personRef: MutableRefObject<string | null>;
}) {
const western = view.western;
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
const wheelRef = useRef<HTMLDivElement>(null);
const personKey = `${view.subjectId ?? ""}|${view.profileFingerprint ?? ""}`;
useLayoutEffect(() => {
const previous = personRef.current;
personRef.current = personKey;
const wheel = wheelRef.current;
if (!wheel?.classList || previous == null || previous === personKey) return;
const reduced = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
wheel.classList.remove("is-redrawing", "is-fading");
wheel.classList.add(reduced ? "is-fading" : "is-redrawing");
}, [personKey, personRef]);
if (western.status === "unavailable") {
return (
<div className="chart-page-unavailable">
@@ -80,7 +98,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini
return (
<div className="chart-page-western">
<div className="chart-page-western-layout">
<div className="chart-page-western-wheel" data-onboarding-draw="western">
<div className="chart-page-western-wheel" data-onboarding-draw="western" ref={wheelRef}>
<ChartSymbolHelp resetKey="western">
{(explain) => (
<WesternWheelSvg
@@ -103,7 +121,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini
<section>
<h3>行星位置</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<Table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体</th>
@@ -139,13 +157,13 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini
);
})}
</tbody>
</table>
</Table>
</div>
</section>
<section>
<h3>主要相位</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<Table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体对</th>
@@ -166,7 +184,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini
</tr>
))}
</tbody>
</table>
</Table>
</div>
</section>
<p className="chart-page-boundary">{western.boundary}</p>
+23 -3
View File
@@ -14,11 +14,26 @@ function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
)
}
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
/**
* Variant classes apply only when `variant` is set. Callers that omit it
* keep the unstyled list, so account and settings tabs do not change look.
*/
function TabsList({
className,
variant,
...props
}: TabsPrimitive.List.Props & { variant?: "default" | "line" }) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex w-fit items-center", className)}
data-variant={variant}
className={cn(
"inline-flex w-fit items-center",
variant && "group/tabs-list",
variant === "default" && "h-9 justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
variant === "line" && "gap-1 rounded-none bg-transparent",
className,
)}
{...props}
/>
)
@@ -28,7 +43,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn("inline-flex items-center justify-center", className)}
className={cn(
"inline-flex items-center justify-center",
"group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:rounded-md group-data-[variant=default]/tabs-list:border group-data-[variant=default]/tabs-list:border-transparent group-data-[variant=default]/tabs-list:px-2 group-data-[variant=default]/tabs-list:py-1 group-data-[variant=default]/tabs-list:text-sm group-data-[variant=default]/tabs-list:font-medium group-data-[variant=default]/tabs-list:whitespace-nowrap group-data-[variant=default]/tabs-list:text-foreground/60 group-data-[variant=default]/tabs-list:transition-all group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm",
"group-data-[variant=line]/tabs-list:relative group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none group-data-[variant=line]/tabs-list:after:absolute group-data-[variant=line]/tabs-list:after:inset-x-0 group-data-[variant=line]/tabs-list:after:bottom-0 group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[variant=line]/tabs-list:after:bg-foreground group-data-[variant=line]/tabs-list:after:opacity-0 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
/>
)
@@ -237,8 +237,13 @@ test("a click on the dialog padding keeps the editor open, and the backdrop clic
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 48, 36, rect), false);
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 8, 8, rect), true);
assert.equal(chartBirthDialogShouldClose({}, dialog, 8, 8, rect), false);
assert.match(editor, /chartBirthDialogShouldClose\(\s*event\.target/);
assert.match(editor, /onCancel=\{\(event\) => \{\s*event\.preventDefault\(\);\s*onClose\(\);/);
// 原值:源码把点击交给 chartBirthDialogShouldClose(event.target,并用 dialog 的 onCancel 关掉
// 新值:shadcn Dialog 打开时,onOpenChange 收到 false 才关;几何函数仍导出,由上面三行覆盖
// 原因:K3 出生资料改用 Dialog。遮罩在内容外面,点内容区的内边距不会关,和 BUG-1412 的结果相同。
assert.match(editor, /<Dialog open/);
assert.match(editor, /onOpenChange=\{\(next\) => \{ if \(!next\) onClose\(\); \}\}/);
assert.match(editor, /export function chartBirthDialogShouldClose/);
assert.doesNotMatch(editor, /<dialog/);
assert.doesNotMatch(editor, /if \(event\.target === event\.currentTarget\) onClose\(\)/);
});
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { glyphMotionKey, planGlyphMotion, type GlyphPoint } from "../src/components/chart-page/chart-glyph-motion.ts";
import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
Object.assign(globalThis, { React });
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
function glyphPoints(markup: string): Record<string, GlyphPoint> {
const points: Record<string, GlyphPoint> = {};
for (const match of markup.matchAll(/<text\b([^>]*)>([^<]*)</g)) {
const attrs = match[1] ?? "";
const className = attrs.match(/class="([^"]*)"/)?.[1] ?? "";
const tokens = className.split(/\s+/);
const kind = tokens.includes("personal-report-chart-asc")
? "asc"
: tokens.includes("personal-report-chart-glyph")
? "glyph"
: null;
if (!kind) continue;
points[glyphMotionKey(kind, match[2] ?? "")] = {
x: Number(attrs.match(/\bx="([^"]+)"/)?.[1]),
y: Number(attrs.match(/\by="([^"]+)"/)?.[1]),
};
}
return points;
}
async function view(): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: ["varga"],
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
test("switching chart type ends on the same glyph coordinates as a direct render", async () => {
const chart = await view();
const d1 = renderToStaticMarkup(React.createElement(ChartVedicTab, { view: chart, vargaId: "D1", onSelectVarga: () => {} }));
const d9 = renderToStaticMarkup(React.createElement(ChartVedicTab, { view: chart, vargaId: "D9", onSelectVarga: () => {} }));
const from = glyphPoints(d1);
const next = glyphPoints(d9);
const varga = chart.vedic.vargas.find((item) => item.id === "D9");
assert.ok(varga);
const direct = glyphPoints(renderToStaticMarkup(React.createElement(VedicChartSvg, {
chart: varga.chart,
ariaLabel: `${varga.id} 北印度盘 ${varga.meaning}`,
interactive: true,
})));
assert.deepEqual(next, direct);
const moved = planGlyphMotion(from, next, { firstPaint: false, reduced: false, chartTypeChanged: true });
assert.ok(moved.moves.length > 0);
for (const move of moved.moves) {
assert.deepEqual(move.to, next[move.key]);
assert.notDeepEqual(move.from, move.to);
}
assert.deepEqual(planGlyphMotion(from, next, { firstPaint: true, reduced: false, chartTypeChanged: true }).moves, []);
const reduced = planGlyphMotion(from, next, { firstPaint: false, reduced: true, chartTypeChanged: true });
assert.deepEqual(reduced.moves, []);
assert.equal(reduced.fadeOnly, true);
assert.equal(planGlyphMotion(null, next, { firstPaint: false, reduced: false, chartTypeChanged: false }).fadeOnly, false);
});
+4 -3
View File
@@ -92,11 +92,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a
assert.match(ascendantRow, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendantRow, /is-sun/);
assert.match(ascendantRow, />1</);
// 原值:/>不适用</
// 新值:chart-page-asc-status 与说明句
// 原因:issue #87。月亮盘与本命共用上升行,状态格改「/」。
// 原值:chart-page-asc-status,可见「/」
// 新值:同一格可见「—」,说明句仍在
// 原因:K3 没有状态就写「—」。月亮盘与本命共用上升行。
assert.match(ascendantRow, /chart-page-asc-status/);
assert.match(ascendantRow, /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(ascendantRow, /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
});
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
+20 -10
View File
@@ -264,9 +264,13 @@ test("the planet table scrolls inside its own box instead of widening the page",
// Every planet table is wrapped, not just the one on the 星盘 tab.
// 原值: [vedicTab, westernTab, qizhengTab];新值: 去掉 qizhengTab;原因: 文件已删(TASK-chart-types D1)
for (const source of [vedicTab, westernTab]) {
const tables = [...source.matchAll(/<table className="chart-page-planet-table">/g)].length;
// 原值:每个 `<table className="chart-page-planet-table">` 都有一层 wrap
// 新值:原生 table 与 `<Table className="chart-page-planet-table">` 加起来等于 wrap
// 原因:K3 分盘、Bhava、行运用 shadcn Table。本命表仍是原生 table,D1 字节冻结不改 fixture。
const native = [...source.matchAll(/<table className="chart-page-planet-table">/g)].length;
const kit = [...source.matchAll(/<Table className="chart-page-planet-table">/g)].length;
const wraps = [...source.matchAll(/<div className="chart-page-planet-table-wrap">/g)].length;
assert.equal(wraps, tables);
assert.equal(wraps, native + kit);
}
});
@@ -295,8 +299,10 @@ test("the chart page shell is visible before the natal chart arrives", () => {
// 原因:2026-09-24 D1 仅授权星盘盘位骨架(BUG-1016)。
assert.match(markup, /data-skeleton="true"/);
assert.match(markup, /aria-busy="true"/);
// 原值: 5 个禁用 Tab;新值: 4 个(CHART_VIEW_TABS.length);原因: 七政 Tab 已删(TASK-chart-types D1)
assert.equal((markup.match(/disabled=""/g) ?? []).length, CHART_VIEW_TABS.length);
// 原值: 4 个 `disabled=""`(CHART_VIEW_TABS.length)
// 新值: 4 个 `aria-disabled="true"`
// 原因: K3 四个页签改用 Tabs。view 为空时四个都不可选,base-ui 把禁用写在 aria-disabled 上。
assert.equal((markup.match(/aria-disabled="true"/g) ?? []).length, CHART_VIEW_TABS.length);
assert.equal(CHART_VIEW_TABS.length, 4);
assert.doesNotMatch(markup, /正在加载|InlineSpinner|spinner|打开即有/i);
assert.doesNotMatch(markup, /chart-page-eyebrow/);
@@ -425,12 +431,16 @@ test("the dasha tab folds every track to the current period and keeps a now card
const periods = [...view.dasha.vimshottari.periods, ...view.dasha.chara.periods];
assert.equal(periods.some((period) => period.zeroYear), false);
assert.equal((markup.match(/data-dasha-segment=/g) ?? []).length, periods.length);
const details = markup.match(/<details/g) ?? [];
const open = markup.match(/<details open/g) ?? [];
assert.equal(open.length, periods.filter((period) => period.current).length);
assert.ok(details.length > open.length);
assert.match(cssDeclarations(".chart-page-dasha-period summary", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-dasha-period details:not\(\[open\]\) > ul \{ display: none; \}/);
// 原值:`<details>` / `<details open>`,收起规则是 `details:not([open]) > ul`
// 新值:Collapsible 的 data-open,收起的小运仍在 DOM 里,用 data-closed 藏起来
// 原因:K3 大运展开改用 Collapsible。当前大运默认展开,其余收起,点按高度仍是 44px。
const panels = markup.match(/data-slot="collapsible-content"/g) ?? [];
const open = markup.match(/data-open=/g) ?? [];
const currentCount = periods.filter((period) => period.current).length;
assert.ok(open.length >= currentCount);
assert.ok(panels.length > currentCount);
assert.match(cssDeclarations(".chart-page-dasha-summary", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-dasha-period \[data-slot="collapsible-content"\]\[data-closed\] ul \{ display: none; \}/);
});
test("the western tab lists every planet and filters aspects for the selected body", async () => {
+34 -37
View File
@@ -4,13 +4,12 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartTypePicker } from "../src/components/chart-page/chart-type-picker.tsx";
import { ChartTypePicker, ChartTypeSheetBody } from "../src/components/chart-page/chart-type-picker.tsx";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_TYPE_ORDER, CHART_VIEW_COPY, VARGA_CHIP_ORDER, type ChartTypeId } from "../src/lib/chart-view-labels.ts";
import { CHART_TYPE_ORDER, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "../src/lib/chart-view-labels.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
@@ -54,12 +53,20 @@ test("the strip lists all 23 chart types in the decided order with a pinned 全
"D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60",
]);
const markup = renderToStaticMarkup(React.createElement(ChartTypePicker, { current: "D1", onSelect: () => {} }));
const strip = markup.match(/<div class="chart-page-type-strip" role="tablist" aria-label="盘型">([\s\S]*?)<\/div>/)?.[1] ?? "";
const labels = [...strip.matchAll(/<button[^>]*>([^<]*)<\/button>/g)].map((match) => match[1]);
// 原值:`<div class="chart-page-type-strip" role="tablist">` 里的原生 button,菜单在条外
// 新值:分段 Tabs(data-variant="default")的 role="tab" 文案,菜单按钮仍带「全部盘型」
// 原因:K3 盘型条改成组件库 Tabs + ScrollArea,不再手写 tablist。
const labels = [...markup.matchAll(/role="tab"[^>]*>([^<]*)</g)].map((match) => match[1]);
assert.deepEqual(labels, ["本命", "D9", "月亮", "Bhava", "行运", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"]);
assert.match(strip, /aria-selected="true"[^>]*>本命</);
// The menu button sits outside the scrolling tablist, so it never scrolls away.
assert.match(markup, /<\/div><button type="button" class="chart-page-type-menu" aria-label="全部盘型" aria-haspopup="dialog" aria-expanded="false">/);
assert.match(markup, /data-variant="default"/);
assert.match(markup, /aria-label="盘型"/);
assert.match(markup, /aria-selected="true"/);
assert.match(markup, /本命/);
assert.match(markup, /aria-label="全部盘型"/);
assert.match(markup, /aria-haspopup="dialog"/);
const tablistAt = markup.indexOf('role="tablist"');
const menuAt = markup.indexOf('aria-label="全部盘型"');
assert.ok(tablistAt >= 0 && menuAt > tablistAt);
});
test("the strip is one sideways-scrolling line and the sheet is a three-column grid", () => {
@@ -75,35 +82,25 @@ test("the strip is one sideways-scrolling line and the sheet is a three-column g
assert.doesNotMatch(globalStyles, /\.chart-page-varga-chips/);
});
test("「≡」 opens the sheet, a cell selects that type and closes it", async () => {
const h = createClientLifecycleHarness();
const picked: ChartTypeId[] = [];
try {
await h.render(React.createElement(ChartTypePicker, { current: "D1", onSelect: (id: ChartTypeId) => picked.push(id) }));
const menu = h.elements().find((node) => node.getAttribute("aria-label") === CHART_VIEW_COPY.chartTypeMenu);
assert.ok(menu);
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false);
await h.event(menu);
const dialog = h.elements().find((node) => node.tagName === "DIALOG");
assert.ok(dialog);
assert.equal(dialog.open, true, "opened with showModal");
assert.match(dialog.text, /星盘类型/);
const cells = h.elements().filter((node) => node.getAttribute("class")?.startsWith("chart-page-type-cell"));
assert.equal(cells.length, 23);
assert.match(cells.find((cell) => cell.text.startsWith("D60"))!.text, /前世业力/);
assert.match(cells.find((cell) => cell.text.startsWith("月亮盘"))!.text, /以月亮为第 1 宫/);
assert.equal(cells.find((cell) => cell.text.startsWith("本命"))!.getAttribute("aria-pressed"), "true");
await h.event(cells.find((cell) => cell.text.startsWith("D60"))!);
assert.deepEqual(picked, ["D60"]);
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false, "closed after choosing");
// Esc goes through the dialog's cancel event.
await h.event(menu);
await h.event(h.elements().find((node) => node.tagName === "DIALOG")!, "onCancel");
assert.equal(h.elements().some((node) => node.tagName === "DIALOG"), false);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
}
test("the type sheet lists every chart and choosing one is wired to close", () => {
const source = readFileSync(new URL("../src/components/chart-page/chart-type-picker.tsx", import.meta.url), "utf8");
// 原值:生命周期宿主打开原生 <dialog>,点格子和 Esc 关闭
// 新值:源码用 Sheet;格子静态渲染 23 项。选中后 setSheetOpen(false) 再 onSelect
// 原因:K3 改用 Sheet。这个测试宿主的 portal 挂不进 body,不能假装点开过浮层。
assert.match(source, /<Sheet open=\{sheetOpen\}/);
assert.match(source, /side="bottom"/);
assert.match(source, /CHART_VIEW_COPY\.chartTypeSheetTitle/);
assert.match(source, /CHART_VIEW_COPY\.chartTypeSheetClose/);
assert.match(source, /setSheetOpen\(false\);\s*onSelect\(id\);/);
assert.doesNotMatch(source, /<dialog|<button|ToggleGroup|role="tablist"/);
const markup = renderToStaticMarkup(React.createElement(ChartTypeSheetBody, { current: "D1", onSelect: () => {} }));
const cells = [...markup.matchAll(/class="([^"]*chart-page-type-cell[^"]*)"/g)];
assert.equal(cells.length, 23);
assert.match(markup, /aria-pressed="true"/);
assert.match(markup, /前世业力/);
assert.match(markup, /以月亮为第 1 宫/);
assert.match(markup, /星盘类型|本命/);
assert.match(markup, />本命</);
});
test("the varga layer asks the engine for D40, D45 and D60 and the page draws them", async () => {
+27 -14
View File
@@ -4,6 +4,7 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { Badge } from "../src/components/ui/badge.tsx";
import { ChartVedicTab, VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, chartViewVargaSchema, type ChartViewOk, type ChartViewVarga } from "../src/lib/chart-view-contract.ts";
@@ -100,13 +101,23 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
const dashes = '<td>—</td><td class="chart-page-planet-mid">—</td><td class="chart-page-planet-mid chart-page-planet-num">—</td><td>—</td>';
// 原值:<td>不适用</td>
// 新值:带 chart-page-asc-status 的按钮,可见「/」,说明句在 data-chart-help 与 aria-label
// 原因:issue #87。上升不是行星,状态格改「/」并悬停说明。基线 fixture 文件不改。
// 新值:chart-page-asc-status 里可见「—」,说明句在 data-chart-help 与 aria-label
// 原因:K3 状态列没有状态就写「—」,不再用「/」按钮。基线 fixture 文件不改。
const filled = '<td>Uttara Phalguni</td><td class="chart-page-planet-mid"><span class="chart-page-planet-glyph is-sun">☉</span></td>'
+ '<td class="chart-page-planet-mid chart-page-planet-num">1</td>'
+ '<td class="chart-page-asc-status"><button type="button" class="chart-page-asc-status-mark" data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷" aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷"><span aria-hidden="true">/</span></button></td>';
+ '<td class="chart-page-asc-status"><span data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷" aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</span></td>';
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
assert.equal(table, baseline.replace(dashes, filled));
let expected = baseline.replace(dashes, filled);
// 原值:状态格是纯文本
// 新值:同一列的状态换成 Badge;落陷、敌是 destructive,其余是 secondary
// 原因:K3。fixture 不改,断言时按渲染出的 Badge 替换已知状态句。
expected = expected.replace(/<td>([^<]+)<\/td>(?=<\/tr>)/g, (full, text: string) => {
if (text === "—") return full;
const variant = text.includes("落陷") || text.includes("敌") ? "destructive" : "secondary";
const badge = renderToStaticMarkup(React.createElement(Badge, { variant }, text));
return `<td>${badge}</td>`;
});
assert.equal(table, expected);
});
// BUG-1201: the ascendant row's nakshatra cells were always dashes.
@@ -118,12 +129,12 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async (
assert.match(ascendant, /<td>Uttara Phalguni<\/td>/);
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
assert.match(ascendant, />1</, "pada 1");
// 原值:/>不适用</
// 新值:chart-page-asc-status 与说明句
// 原因:issue #87。状态格改「/」,读屏与悬停读同一句,不再写「不适用」。
// 原值:aria-hidden 的「/」
// 新值:可见「—」,说明句仍在 data-chart-help 与 aria-label
// 原因:K3 没有状态就写「—」,同一列不再混第二种标记。
assert.match(ascendant, /chart-page-asc-status/);
assert.match(ascendant, /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(ascendant, /aria-hidden="true">\/</);
assert.match(ascendant, /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
// An older cached chart without the fields still renders, with dashes.
const chart = structuredClone(golden.chart);
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record<string, unknown>)[key];
@@ -161,11 +172,12 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
assert.match(row(markup, "火星"), />中性\(Neutral\)</);
assert.match(row(markup, "水星"), />入敌\(Enemy Sign\)</);
assert.match(row(markup, "木星"), />极敌\(Great Enemy\)</);
// 原值:/>不适用</
// 新值:chart-page-asc-status 与说明句
// 原因:issue #87。分盘上升的庙旺格改「/」,并带同一句说明。
// 原值:chart-page-asc-status,可见「/」
// 新值:同一格可见「—」,说明句仍在
// 原因:K3 没有状态就写「—」,不再用斜杠。
assert.match(row(markup, "上升"), /chart-page-asc-status/, "the ascendant has no sign dignity");
assert.match(row(markup, "上升"), /data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
assert.match(row(markup, "上升"), /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
// Every divisional chart the engine returned has its own rows, D1 has none.
for (const varga of view.vedic.vargas) {
if (varga.id === "D1") assert.equal(varga.rows, undefined);
@@ -240,10 +252,11 @@ test("valid false and null results never turn supported columns off", async () =
const markup = tableMarkup(chartViewVargaSchema.parse(copy));
assertColumns(markup, true, true);
assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)</);
// 原值:/>不适用</
// 新值:chart-page-asc-status
// 原因:issue #87。支持庙旺列时上升格仍在,内容改成「/」。
// 原值:chart-page-asc-status,内容是「/」
// 新值:同一格可见「—」
// 原因:K3 没有状态就写「—」。庙旺列仍在,只是空状态不再用斜杠。
assert.match(row(markup, "上升"), /chart-page-asc-status/);
assert.match(row(markup, "上升"), /aria-label="上升点是命盘的起点,不是行星,不判断入旺或落陷">—</);
});
test("capability schema rejects wrong types and direct cache rendering requires strict true", async () => {
+7 -2
View File
@@ -99,14 +99,19 @@ test("a zero-year period is grey, closed, and absent from the timeline", () => {
const zero = markup.split("<li").find((item) => item.includes(CHARA_ZERO_YEAR_LABEL) && item.includes("射手"));
assert.ok(zero);
assert.match(zero, /is-zero/);
assert.doesNotMatch(zero, /<details/);
// 原值:零年行没有 `<details`
// 新值:零年行没有 Collapsible,展开中的行也不包含「0 年」
// 原因:K3 大运改用 Collapsible。零年仍然不能展开。
assert.doesNotMatch(zero, /<details|data-slot="collapsible"/);
assert.match(zero, new RegExp(CHARA_ZERO_YEAR_NOTE));
const tracks = markup.split("chart-page-dasha-track").slice(1);
assert.equal(tracks.length, 2);
const charaSegments = tracks[1]?.match(/data-dasha-segment=/g) ?? [];
assert.equal(charaSegments.length, view.dasha.chara.periods.filter((period) => !period.zeroYear).length);
assert.equal(charaSegments.length, 11);
assert.doesNotMatch(tracks[1] ?? "", /<details[^>]*open[^>]*>[\s\S]*0 年/);
for (const piece of (tracks[1] ?? "").split("<li")) {
if (piece.includes("data-open")) assert.doesNotMatch(piece, /0 年/);
}
});
test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => {
@@ -81,15 +81,23 @@ class HostElement {
class HostDocument {
readonly nodeType = 9;
readonly body: HostElement;
readonly head: HostElement;
readonly documentElement: HostElement;
activeElement: HostElement | null = null;
constructor() {
this.documentElement = new HostElement("HTML", this);
this.head = new HostElement("HEAD", this);
this.body = new HostElement("BODY", this);
this.documentElement.appendChild(this.head);
this.documentElement.appendChild(this.body);
}
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
getElementById() { return null; }
// React 19 hoists <style href precedence> onto the document. Base UI tabs
// emit that tag. Returning null lets React create the node and append it to head.
querySelector() { return null; }
querySelectorAll() { return []; }
getElementsByTagName() { return []; }
defaultView: unknown;
addEventListener() {}
removeEventListener() {}