From b7ec8e78ed4d280b25f5180cd7302e3ec3ef085c Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 25 Oct 2026 04:02:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=9F=E7=9B=98=E9=A1=B5=E6=8D=A2=E6=88=90?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=BA=93=EF=BC=9A=E9=A1=B5=E7=AD=BE=E3=80=81?= =?UTF-8?q?=E7=9B=98=E5=9E=8B=E6=9D=A1=E3=80=81=E5=8F=AF=E6=8B=96=E5=88=86?= =?UTF-8?q?=E6=A0=8F=E5=92=8C=E6=8D=A2=E7=9B=98=E5=8A=A8=E6=95=88=EF=BC=88?= =?UTF-8?q?#107=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个页签用下划线,盘型条用分段并可以横滑,全部盘型从底部打开。桌面上盘和参数可以拖宽,窄屏上下排。行星表仍跨满整行。换盘型时星体移到新位置;减少动态效果只淡入。第一次进场不重播。 自测: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) --- CHANGELOG.md | 10 + docs/BUG_HISTORY.md | 16 + .../tasks/PROGRESS-ui-kit-3-chart-20261023.md | 64 ++++ docs/testing/ui-kit-migration-20261023.md | 12 + frontend/DESIGN.md | 24 +- frontend/eslint/ui-primitive-whitelist.json | 5 - frontend/src/app/globals.css | 67 ++++- .../chart-page/chart-birth-editor.tsx | 82 ++---- .../components/chart-page/chart-dasha-tab.tsx | 154 +++++++--- .../chart-page/chart-glyph-motion.ts | 35 +++ .../components/chart-page/chart-page-view.tsx | 46 +-- .../chart-page/chart-type-picker.tsx | 182 ++++++------ .../components/chart-page/chart-vedic-tab.tsx | 275 +++++++++++++++--- .../chart-page/chart-western-tab.tsx | 34 ++- frontend/src/components/ui/tabs.tsx | 26 +- ...art-birth-editor-and-report-table.test.tsx | 9 +- frontend/tests/chart-glyph-motion.test.ts | 91 ++++++ frontend/tests/chart-moon-transit.test.tsx | 7 +- frontend/tests/chart-page-view.test.tsx | 30 +- frontend/tests/chart-type-picker.test.tsx | 71 +++-- frontend/tests/chart-varga-table.test.tsx | 41 ++- frontend/tests/chart-view-zero-year.test.ts | 9 +- .../react-client-lifecycle-test-support.ts | 8 + 23 files changed, 959 insertions(+), 339 deletions(-) create mode 100644 docs/tasks/PROGRESS-ui-kit-3-chart-20261023.md create mode 100644 frontend/src/components/chart-page/chart-glyph-motion.ts create mode 100644 frontend/tests/chart-glyph-motion.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c29c6a83..b80f6efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 印度占星 Skill 更新日志 +## 2026-10-25 — 星盘页的页签和盘型条换成组件库 + +- 四个页签仍是星盘、基础信息、大运、西洋盘,当前项有下划线。 +- 本命、D9、月亮这些盘型是一排可以横滑的分段。点「全部盘型」从底部打开 23 种盘。 +- 桌面(不窄于 1024)上,盘和参数可以拖宽。手机上盘在上、参数在下。行星表仍然横跨整行。 +- 行星状态用同一种小标记。落陷和带「敌」的用强调色,其余用次要色。没有状态写「—」。 +- 点顶栏的日期地点可以改出生资料。表单仍是档案页那一份。 +- 换盘型时,星体移到新的宫位。换人时格子重画。大运点开才展开。系统开了减少动态效果时,这些只淡入,星体不飞。第一次打开时的进场不重播。 +- Skill 版本不变(BUG-1435)。 + ## 2026-10-25 — 输入框里选模型,桌面旁边可以打开本命盘 - 输入框里左边选模型,右边是发送。各模型的价格只写在菜单里。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c9e7e3a3..cd2200d1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-3-chart-20261023.md b/docs/tasks/PROGRESS-ui-kit-3-chart-20261023.md new file mode 100644 index 00000000..b84be8f3 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-3-chart-20261023.md @@ -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%。宽度不记到本机。分栏零件在这一块载入后再出现,不进首页同步包。更窄或还没载入时,盘在上、参数在下。行星表仍是整行的第三格。 +- 本命和月亮的八列表仍是原来的 ``,这样 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 与 `
` 加起来等于外包层 | 分盘、Bhava、行运用组件库表格。本命表仍是原生 table | +| 大运展开 | `
` / `details:not([open])` | `data-slot="collapsible-content"`,收起用 `data-closed` | 大运改用 Collapsible。当前段默认展开,点按高度仍是 44px | +| 零年大运 | 零年行没有 `` | 源码是 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` 的「星盘页」一节。 diff --git a/docs/testing/ui-kit-migration-20261023.md b/docs/testing/ui-kit-migration-20261023.md index 90b13bca..8809bf61 100644 --- a/docs/testing/ui-kit-migration-20261023.md +++ b/docs/testing/ui-kit-migration-20261023.md @@ -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 会再要一次。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a20b2dd0..38e7c066 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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="全部盘型"`),在滚动容器外面,不会被滚走。点它打开原生 ``「星盘类型」:标题 + 关闭,三列网格(`.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`),不新造一套色。 -列表用原生 `
` / ``。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `
`,文字用 `--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) diff --git a/frontend/eslint/ui-primitive-whitelist.json b/frontend/eslint/ui-primitive-whitelist.json index d3ff8c7e..b7a17fa7 100644 --- a/frontend/eslint/ui-primitive-whitelist.json +++ b/frontend/eslint/ui-primitive-whitelist.json @@ -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", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 05f32a10..8e589e70 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/components/chart-page/chart-birth-editor.tsx b/frontend/src/components/chart-page/chart-birth-editor.tsx index 5557e6ae..b80c33b7 100644 --- a/frontend/src/components/chart-page/chart-birth-editor.tsx +++ b/frontend/src/components/chart-page/chart-birth-editor.tsx @@ -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; }) { - const dialogRef = useRef(null); const { account, setAccount } = useSessionList(); const [library, setLibrary] = useState(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 ( - { - event.preventDefault(); - onClose(); - }} - onClick={(event) => { - if (chartBirthDialogShouldClose( - event.target, - event.currentTarget, - event.clientX, - event.clientY, - event.currentTarget.getBoundingClientRect(), - )) onClose(); - }} - > - {failed ? ( -

- 人物列表暂时读不出来 - -

- ) : library === null ? ( - - ) : !record || !draft || !baseline ? ( -

这份资料暂时读不出来

- ) : ( - void save(event)} - /> - )} -
+ { if (!next) onClose(); }}> + + {title} + {failed ? ( +

+ 人物列表暂时读不出来 + +

+ ) : library === null ? ( + + ) : !record || !draft || !baseline ? ( +

这份资料暂时读不出来

+ ) : ( + void save(event)} + /> + )} +
+
); } diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 41002a5c..4c7278e2 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -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 ( +
    + {period.antardashas.map((antar) => ( +
  • + {antar.label} · + {formatYearMonth(antar.start)} – {formatYearMonth(antar.end)} +
  • + ))} +
+ ); +} + +function DashaPeriodRow({ period }: { period: DashaPeriod }) { + const [open, setOpen] = useState(Boolean(period.current)); + const [animate, setAnimate] = useState(false); + const motion = useDashaMotion(animate); + const list = ; + 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 = ( + + {list} + + ); + } + return ( +
  • + { + setAnimate(dashaMotion != null); + setOpen(next); + }} + > + + {period.label} + {formatYearMonth(period.start)} – {formatYearMonth(period.end)} + + + {body} + + +
  • + ); +} + function Track({ title, method, @@ -123,27 +215,7 @@ function Track({

    {CHARA_ZERO_YEAR_NOTE}

    ) : ( -
  • -
    - - {period.label} - {formatYearMonth(period.start)} – {formatYearMonth(period.end)} - - {period.antardashas.length > 0 ? ( -
      - {period.antardashas.map((antar) => ( -
    • - {antar.label} · - {formatYearMonth(antar.start)} – {formatYearMonth(antar.end)} -
    • - ))} -
    - ) : null} -
    -
  • + ) ))} @@ -169,6 +241,7 @@ export function ChartDashaTab({ }) { const [uncontrolledSystem, setUncontrolledSystem] = useState(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 = ( -
    - {DASHA_SYSTEMS.map((item) => ( - - ))} +
    + { + if (value === "chara" || value === "yogini") selectSystem(value); + }} + > + + {DASHA_SYSTEMS.map((item) => ( + { + // The active tab does not emit onValueChange. Clicking it again retries that layer. + if (item.id === system) selectSystem(item.id); + }} + > + {item.label} + + ))} + +
    ); diff --git a/frontend/src/components/chart-page/chart-glyph-motion.ts b/frontend/src/components/chart-page/chart-glyph-motion.ts new file mode 100644 index 00000000..e93cc3af --- /dev/null +++ b/frontend/src/components/chart-page/chart-glyph-motion.ts @@ -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> | null, + next: Readonly>, + 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 }; +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index effb698b..f20b83fb 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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" ? ( - + ) : null; @@ -190,22 +193,27 @@ export function ChartPageView({ ) : (
    -
    - {CHART_VIEW_TABS.map((item) => ( - - ))} -
    + { + if (value) selectTab(value as ChartViewTabId); + }} + > + + {CHART_VIEW_TABS.map((item) => ( + + {item.label} + {journey?.tabHasDot(item.id) ? : null} + + ))} + +
    {view == null ? : <> {tab === "vedic" ? ( diff --git a/frontend/src/components/chart-page/chart-type-picker.tsx b/frontend/src/components/chart-page/chart-type-picker.tsx index 9a8ed079..116d3d03 100644 --- a/frontend/src/components/chart-page/chart-type-picker.tsx +++ b/frontend/src/components/chart-page/chart-type-picker.tsx @@ -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 - * `` 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(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 ( - { event.preventDefault(); onClose(); }} - onClick={(event) => { if (event.target === event.currentTarget) onClose(); }} - > -
    -
    -

    {CHART_VIEW_COPY.chartTypeSheetTitle}

    - -
    -
    - {CHART_TYPE_ORDER.map((id) => { - const copy = sheetCopy(id); - return ( - - ); - })} -
    -
    -
    +
    + {CHART_TYPE_ORDER.map((id) => { + const copy = sheetCopy(id); + return ( + + ); + })} +
    ); } /** - * 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()); + const triggers = useRef(new Map()); 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 (
    -
    - {CHART_TYPE_ORDER.map((id) => ( - - ))} -
    - - {sheetOpen ? ( - setSheetOpen(false)} - onSelect={chooseFromSheet} - /> - ) : null} + + { + if (value) onSelect(value as ChartTypeId); + }} + > + + {CHART_TYPE_ORDER.map((id) => ( + { + 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} + + ))} + + + + + + + + {CHART_VIEW_COPY.chartTypeSheetTitle} + + + + + +
    ); } diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 73300f0a..8e3a14db 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -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 }) {
    - + ); } @@ -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 {text}; +} + function AscendantStatusCell({ className }: { className?: string }) { return ( ); } /** * 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 (
    -
    {row.nakshatra} {row.pada}{row.status}
    - + —
    +
    @@ -222,7 +227,7 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { {varga.ascendant ? : null} {(varga.rows ?? []).map((row) => )} -
    {varga.id} · {varga.meaning}
    + ); @@ -236,7 +241,7 @@ function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) { function TransitTable({ transit }: { transit: ChartViewTransitOk }) { return (
    - +
    @@ -263,7 +268,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { ); })} -
    行运 · {transit.date} {transit.time}({transit.timezoneLabel})
    +
    ); } @@ -272,7 +277,7 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { return ( <>
    - +
    @@ -299,10 +304,10 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { ); })} -
    Bhava · {bhava.houseSystem}
    +
    - +
    @@ -320,12 +325,180 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { ))} -
    宫头与宫界
    +
    ); } +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 ( + + + {stage} + + + + {params} + + + ); +} + +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 { + const nodes = root.querySelectorAll?.(".personal-report-chart-glyph, .personal-report-chart-asc"); + if (!nodes || typeof nodes.forEach !== "function") return {}; + const next: Record = {}; + 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(); + 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(null); + const positions = useRef | null>(null); + const seenType = useRef(null); + const seenPerson = useRef(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 = ( +
    + +
    + +
    +
    +
    + ); + const params = ( +
    + + {/* 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. */} +

    {view.vedic.boundary}

    +
    + ); return (
    -
    - -
    - -
    -
    -
    -
    - - {/* 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. */} -

    {view.vedic.boundary}

    -
    +
    {children}
    diff --git a/frontend/src/components/chart-page/chart-western-tab.tsx b/frontend/src/components/chart-page/chart-western-tab.tsx index c6005388..0a83d2c8 100644 --- a/frontend/src/components/chart-page/chart-western-tab.tsx +++ b/frontend/src/components/chart-page/chart-western-tab.tsx @@ -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(null); const resetKey = JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, view.western]); - return ; + return ; } -function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; initialSelectedId: string | null }) { +function WesternTabSession({ view, initialSelectedId, personRef }: { + view: ChartViewOk; + initialSelectedId: string | null; + personRef: MutableRefObject; +}) { const western = view.western; const [selectedId, setSelectedId] = useState(initialSelectedId); + const wheelRef = useRef(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 (
    @@ -80,7 +98,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini return (
    -
    +
    {(explain) => (

    行星位置

    - +
    @@ -139,13 +157,13 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini ); })} -
    星体
    +

    主要相位

    - +
    @@ -166,7 +184,7 @@ function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; ini ))} -
    星体对
    +

    {western.boundary}

    diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 53908a00..7d0a14c9 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -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 ( ) @@ -28,7 +43,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { return ( ) diff --git a/frontend/tests/chart-birth-editor-and-report-table.test.tsx b/frontend/tests/chart-birth-editor-and-report-table.test.tsx index f45b4b65..c5c2632c 100644 --- a/frontend/tests/chart-birth-editor-and-report-table.test.tsx +++ b/frontend/tests/chart-birth-editor-and-report-table.test.tsx @@ -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, / \{ if \(!next\) onClose\(\); \}\}/); + assert.match(editor, /export function chartBirthDialogShouldClose/); + assert.doesNotMatch(editor, / { + const points: Record = {}; + for (const match of markup.matchAll(/]*)>([^<]*) { + 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); +}); diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx index 2bac63f6..e7533191 100644 --- a/frontend/tests/chart-moon-transit.test.tsx +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -92,11 +92,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a assert.match(ascendantRow, /Uttara Phalguni<\/td>/); assert.match(ascendantRow, /is-sun/); assert.match(ascendantRow, />1不适用— { diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 09a32d9a..cb86aead 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -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(//g)].length; + // 原值:每个 `
    ` 都有一层 wrap + // 新值:原生 table 与 `
    ` 加起来等于 wrap + // 原因:K3 分盘、Bhava、行运用 shadcn Table。本命表仍是原生 table,D1 字节冻结不改 fixture。 + const native = [...source.matchAll(/
    /g)].length; + const kit = [...source.matchAll(/
    /g)].length; const wraps = [...source.matchAll(/
    /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(/
    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: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 () => { diff --git a/frontend/tests/chart-type-picker.test.tsx b/frontend/tests/chart-type-picker.test.tsx index 964a38e2..371e255a 100644 --- a/frontend/tests/chart-type-picker.test.tsx +++ b/frontend/tests/chart-type-picker.test.tsx @@ -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(/
    ([\s\S]*?)<\/div>/)?.[1] ?? ""; - const labels = [...strip.matchAll(/]*>([^<]*)<\/button>/g)].map((match) => match[1]); + // 原值:`
    ` 里的原生 button,菜单在条外 + // 新值:分段 Tabs(data-variant="default")的 role="tab" 文案,菜单按钮仍带「全部盘型」 + // 原因:K3 盘型条改成组件库 Tabs + ScrollArea,不再手写 tablist。 + const labels = [...markup.matchAll(/role="tab"[^>]*>([^<]*) 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"[^>]*>本命
    '; // 原值: - // 新值:带 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 = '' + '' - + ''; + + ''; 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(/`; + }); + 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, /
    ————不适用Uttara Phalguni☉1—([^<]+)<\/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 `${badge}Uttara Phalguni<\/td>/); assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun"); assert.match(ascendant, />1不适用\/—)[key]; @@ -161,11 +172,12 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co assert.match(row(markup, "火星"), />中性\(Neutral\)入敌\(Enemy Sign\)极敌\(Great Enemy\)不适用—([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|\((?:Exalted|Own Sign|Debilitated|Neutral)\)不适用— { diff --git a/frontend/tests/chart-view-zero-year.test.ts b/frontend/tests/chart-view-zero-year.test.ts index 3df0e01f..f89af501 100644 --- a/frontend/tests/chart-view-zero-year.test.ts +++ b/frontend/tests/chart-view-zero-year.test.ts @@ -99,14 +99,19 @@ test("a zero-year period is grey, closed, and absent from the timeline", () => { const zero = markup.split(" item.includes(CHARA_ZERO_YEAR_LABEL) && item.includes("射手")); assert.ok(zero); assert.match(zero, /is-zero/); - assert.doesNotMatch(zero, /
    !period.zeroYear).length); assert.equal(charaSegments.length, 11); - assert.doesNotMatch(tracks[1] ?? "", /]*open[^>]*>[\s\S]*0 年/); + for (const piece of (tracks[1] ?? "").split(" { diff --git a/frontend/tests/react-client-lifecycle-test-support.ts b/frontend/tests/react-client-lifecycle-test-support.ts index 41350e11..eff062f3 100644 --- a/frontend/tests/react-client-lifecycle-test-support.ts +++ b/frontend/tests/react-client-lifecycle-test-support.ts @@ -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