diff --git a/CHANGELOG.md b/CHANGELOG.md index 959d545b..f4d7d73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-25 — 报告、星历和登录页换成组件库 + +- 报告的中文和 English 是一排分段。切换之后,页面上不留下上一种语言的正文。 +- 导出从桌面右侧滑出,窄屏从底部滑出。不再显示文件大小,也不再写那句给 AI 的说明。按钮仍是「导出整篇」或「导出 N 项」。 +- 星历左右是箭头。点前一天或后一天,下面的卡片按这个方向翻,边框不会被切掉。 +- 登录页的输入框和按钮换成组件库。六位验证码是分开的六格。恢复码仍是一条输入框。 +- 报告正文、导出的文件、发验证码和校验、扣点,都没有改。那一刻的天空仍是原来的星星汇聚,只换了关闭和分享两枚按钮。 +- Skill 版本不变(BUG-1437)。 + ## 2026-10-25 — 星盘档案可以拖着排,合盘先确认扣点 - 星盘档案的人物卡可以拖动排序。刷新、换一台登录的设备,顺序还在。本人始终排在第一张,拖不动。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5bf524ba..b9c870a7 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15106,7 +15106,7 @@ - 根因:`PersonalReportMarkdownView` 与 `ReportFactTables` 是同级元素,key 都是 `shown`,发生重复;React 清不掉旧节点,旧语言正文留在最上面,新语言正文追加在后面(Claude 在 staging 线上 JS 上复现:正文区块 1→2→3 份,开发版报 `Encountered two children with the same key`)。响应里没有核对表时不复现,所以只测组件的用例(`report-english-edition.test.tsx`)没有拦住。引入提交 `b2ad772e`。 - 修复:两处 key 改为 `markdown-${shown}` / `facts-${shown}`,仍随语言重新挂载,不再重复。 - 验证:新增 `frontend/tests/report-language-switch-20260930.test.tsx`(整页渲染 + 真实引擎虚构盘 golden 的中英正文与核对表;中→英→中、英→中两条):修复前 2 条均失败(正文区块 2 份),修复后通过;前端全量失败清单与基线逐条一致;tsc / lint / build 见 `docs/tasks/PROGRESS-report-language-switch-stale-20260930.md`。浏览器级走查见 `docs/testing/report-english-edition-20260929.md` 第 10、11 步。 -- 防复发:同一父级下按语言重新挂载的多个子元素,key 必须带各自前缀;语言切换要有整页测试,数据带齐核对表。 +- 防复发:同一父级下按语言重新挂载的多个子元素,key 必须带各自前缀;语言切换要有整页测试,数据带齐核对表。2026-10-25 组件库 K5 把中英切换换成了分段页签(`role="tab"`、`aria-selected`)。`frontend/tests/report-language-switch-20260930.test.tsx` 仍断言切换后正文只有一份、上一种语言的节点不残留,这条防复发还在。 - 相关记录:BUG-1106~1108(跨语言保留章节) - 修复版本:分支 `codex/report-language-switch-stale-20260930` @@ -19478,3 +19478,20 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-4-people-20261023`。 +## BUG-1437 | 报告、星历、登录与其余页面换成组件库 + +- 状态:enhancement +- 首次发现:2026-10-25 +- 最近更新:2026-10-25 +- 影响面:个人报告的中英切换、导出、列表删除和跳到文末;星历翻日期;登录页的输入、按钮和验证码;协议勾选;代码块复制;全站崩溃页的两个按钮;那一刻的天空回放上的关闭和分享。报告正文、导出文件、发码和校验、扣点,都不在这次的改动里。 +- 用户现象:报告语言是一排分段,点中文或 English 后,页面上不留下上一种语言的正文。导出从桌面右侧或手机底部滑出,不再写文件大小,也不再写那句给 AI 的说明。星历左右箭头翻日期时,下面的卡片跟着翻,边框不会被切掉。登录页的六位验证码是分开的六格。 +- 触发条件:打开一份个人报告后切换语言或点导出;在星历上点前一天或后一天;打开登录页填写验证码。 +- 根因:不是缺陷。组件库迁移的报告、星历和登录这一张(任务书 K5,决策 D4、D7、D8、D9)。 +- 修复:中英切换和导出里的语言、范围改用分段页签。导出改用从右侧或底部滑出的面板,去掉说明句和文件大小。星历日期箭头改用按钮和图标,内容按方向翻页,翻页容器不裁边框。登录的输入、按钮和六位验证码改用组件库;恢复码仍是一条输入框。天空回放只换关闭和分享两枚按钮,星星汇聚不动。验证码格子和登录页按需加载,不进首页同步包。 +- 验证:见 `docs/tasks/PROGRESS-ui-kit-5-pages-20261023.md`。没有浏览器登录态,不把登录或导出报告写成已经在浏览器里做过。 +- 防复发:`frontend/tests/report-language-switch-20260930.test.tsx` 锁住切换后正文只有一份。导出合同锁住没有文件大小、没有说明句,以及面板从右侧或底部出现。白名单测试锁住名单只减不增。 +- 相关记录:issue #109。任务书 `docs/tasks/TASK-ui-kit-migration-20261023.md` 的 K5。BUG-1126 的防复发测试仍在。星星汇聚沿用 #99,本张不改 `birth-sky-converge.tsx`。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-5-pages-20261023`。 + + diff --git a/docs/tasks/PROGRESS-ui-kit-5-pages-20261023.md b/docs/tasks/PROGRESS-ui-kit-5-pages-20261023.md new file mode 100644 index 00000000..1a8ac136 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-5-pages-20261023.md @@ -0,0 +1,68 @@ +# PROGRESS · 组件库 K5 报告、星历、登录与其余页面(2026-10-25) + +工单 #109。分支 `codex/ui-kit-5-pages-20261023`。基线 `origin/staging` `eee8564b7f43b766c5711a94541d5703fd245d99`(#108 档案与合盘已在这个提交里)。只做任务书 **K5 报告、星历、登录与其余页面**。K6 生时校正和 K7 收尾没做。没有重做档案、合盘、星盘页、对话页、侧栏。 + +## 结论 + +报告的中文和 English 是一排分段。切换之后,页面上不留下上一种语言的正文。导出从桌面右侧滑出,窄屏从底部滑出,不再显示文件大小,也不再写那句给 AI 的说明。星历左右箭头翻日期时,下面的卡片按方向翻,边框不会被切掉。登录页的输入框、按钮和六位验证码换成了组件库。 + +## 做了什么 + +- 报告语言用分段式 `Tabs`,`aria-label="报告语言"`。导出抽屉里的语言是 `aria-label="导出语言"`,范围是 `aria-label="导出范围"`。不用描边的 `ToggleGroup`。切换语言仍按原来的 key 整段重挂,没有加交叉淡入。 +- 导出用 `Sheet`。宽屏从右侧滑出,宽度最多 520px。767px 以下从底部滑出,最高 85dvh。页脚是「导出整篇」「导出 N 项」或不能点的「先勾选要导出的部分」。没有说明句,没有文件大小。没有英文版时,打开后仍提示一次,句子和原来一样。下载、打印限制、文件名和勾选规则没有改。 +- 报告列表的更多改成菜单,删除前仍问「确定删除这份报告?」。跳到文末的按钮换成组件库按钮,箭头换成图标。刷新按钮也换成组件库按钮,读屏名仍是「刷新报告列表」。 +- 星历的前一天、后一天是按钮加 Lucide 箭头,读屏名不变。日期下面的内容按方向翻页,用已有的 Motion。翻页容器不裁切,四周留 1px,卡片描边留得住。减少动态效果时只淡入。没有转圈。 +- 登录页的输入框和按钮换成组件库。六位验证码用 `InputOTP`,格子是 `size-11`。恢复码仍是一条 `.otp-input`,高度 `h-11`,字号仍是 20px。验证码登录 / 密码登录,以及二步验证里的动态验证码 / 恢复码,是分段页签。注册账号、忘记密码、返回登录是链接样式的按钮。同意勾选换成组件库复选框。发码和校验没有改。 +- 代码块的复制、全站崩溃页的重试和返回对话,换成组件库按钮。崩溃页的颜色仍写在本文件里。 +- 那一刻的天空只换了回放上的关闭和分享。星星汇聚留在 `birth-sky-converge.tsx`,没有重写。 +- 新增零件 `input-otp@1.5.0`(2026-08-18 发布),用固定的 `shadcn@4.21.4` 添加。没有升级其他依赖。 +- 白名单去掉 12 个文件,没有加长。测试里写死的基线名单没有改。`personal-report-markdown-view.tsx` 和星星汇聚仍留在名单里。 +- 没有改 `.gitea/**`,没有改生产部署,没有改数据库,没有给 `scripts/jyotish_api_server.py` 加方法,没有改 `page.tsx`。`Home()` 的 useState / useRef 没有增长。 + +## 断言改动(原值 / 新值 / 原因) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 报告语言选中态 | `BUTTON` 且 `aria-pressed="true"` | `role="tab"` 且 `aria-selected="true"` | K5 改用分段页签。正文仍只能有一份 | +| 导出语言未选中 | `BUTTON`,`aria-pressed="false"` | `role="tab"`,`aria-selected="false"` | 同上 | +| 导出单选 | `role="radio"` 加 `aria-checked` | `role="tab"`。选中看 `aria-selected` | 导出语言和导出范围改用页签 | +| 导出容器 | 原生 `dialog`,窄屏底部、桌面居中 | `Sheet`。桌面 `data-side="right"`,窄屏 `data-side="bottom"` | 桌面从右侧滑出,手机从底部滑出 | +| 导出按钮文案 | 「导出整篇 · 约 N KB」 | 「导出整篇」,看不到「约 N KB」 | D8 去掉文件大小 | +| 导出说明句 | 英文可用时正文含「建议选 English」 | 没有这句 | D8 删掉说明句。没有英文版时的提示仍在 | +| 关闭导出 | 原生 dialog 的 Esc 和点遮罩 | 点「关闭导出」关闭。Esc 和点遮罩由 Sheet 交给 `onOpenChange` | 不再使用原生 dialog | +| 下载测试的文档 | 把 `document.body` 换成空对象 | 留下测试文档的 body,只把下载锚点换成假的 | Sheet 要挂到 body 上 | +| 自选章节的查找 | 页签文字必须正好等于「自选章节」 | 先精确匹配,再按前缀匹配 | 这一格还带着「只带走需要的部分」 | +| 删除入口 | 行内 `details` 里的「删除」,取消后焦点回 `SUMMARY` | 先点「报告更多操作」,再点菜单里的「删除」。确认框里的「删除」是按钮 | 换成菜单和确认框 | +| 第二次删除失败 | `buttons(h, "删除")[1]` | 文字为「删除」的最后一枚 | 菜单关上后,菜单项不留在文档里 | +| 登录同意勾选 | 原生 `INPUT`,`onChange` 带 `checked: true` | `role="checkbox"`,点它把 `true` 交给 `onChange` | 同意换成组件库复选框 | +| 崩溃页引用 | `global-error` 不得出现任何 import | 只允许 `import { Button }` | 两个按钮换成组件库。颜色仍写在本文件里 | +| 天空回放按钮 | 两枚原生 `button`,类名 `birth-sky-control` / `birth-sky-share` | 两枚 `Button`,类名、读屏名「关闭」、分享仍调用 `share()` | 只换控件,不改星星汇聚 | +| 白名单 | 含这 12 个报告、星历、登录和其余页面文件 | 这 12 个移出 | 它们已经改用组件库。名单只许变短 | + +移出的 12 个文件:`global-error.tsx`、`birth-sky-replay.tsx`、`email-otp-login.tsx`、`ephemeris-page.tsx`、`legal-consent-gate.tsx`、`login-consent.tsx`、`markdown-code-block.tsx`、`personal-report-center.tsx`、`report-actions.tsx`、`report-edge-jump.tsx`、`report-export-drawer.tsx`、`report-row-delete.tsx`。 + +BUG-1126 的「正文只有一份」和「切换后不含上一种语言的汉字」没有改弱。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | 仓库根 `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0。第一次因测试里的嵌套函数报 TS2684,改成箭头函数后通过 | +| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。122 个警告,0 error。警告数和上一张记下的一样,没有为这一张新加 | +| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 一批合同文件 | 185 条,通过 184,失败 1。失败的是 `admin-operation-reauth-removal` 第 1 条:Windows 上把文件网址的路径拼成了 `G:\G:\...`,这条在改 K5 之前就会这样。同一文件第 3 条(登录页仍有「邮箱验证码登录」「验证并登录」)通过。BUG-1126 的两条在这批里通过 | +| 英文版与删除 | 同上,单独重跑 `report-english-edition.test.tsx`、`report-row-delete.test.tsx` | 英文版 10 条全部通过。删除 5 条全部通过 | +| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。编译 10.8s,TypeScript 115s。79 个静态页。`/` 仍是 ○ Static。`/login` 是动态。`/ephemeris`、`/reports` 是静态。5 条既有 skill-package-registry 警告,没有改 | +| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加,不含 CSS | 基线 `eee8564b`(上一张记下的数):36 个文件,639609 字节。本张:35 个文件,缺失 0,642174 字节,多 2565(+0.40%)。±2% 大约是 626817 到 652401。`input-otp`、`email-otp-login`、`InputOTP`、`report-export-drawer`、`ephemeris-page`、`birth-sky-replay` 命中都是 0 | + +没有跑前端全量 `npm test`,没有跑 Python 快速门,没有跑 `test:db`。`tests/test_repo_privacy_markers.py` 退出码 0。`page.tsx` 和 `scripts/jyotish_api_server.py` 没有改。 + +## 偏离 + +- 全站崩溃页原来不允许任何 import。现在只引入按钮组件,颜色仍写在本文件的内联样式里。合同按三栏改过。没有改崩溃时的两句文案。 +- 没有英文版时的提示,从抽屉组件自己的挂载挪到了抽屉内容里的一个空子节点。原因是面板要等传送门挂上之后才出现在文档里,挂在外层时提示会早于面板。句子、编号和只提示一次都没变。 +- 测试环境里,传送门要在模块加载时已经看得到 `document`,否则布局效果会被换成空函数,面板和菜单永远挂不出来。合同测试在引入这些组件之前先放一个占位文档,真正渲染前仍换成原来的测试文档。复选框、页签排序和菜单打开需要的指针事件、文档位置比较和观察器,也补在这个测试文档上。没有改组件库零件本身。 +- `personal-report-markdown-view.tsx` 不在本张文件清单里,没有改。星星汇聚没有改。 + +## 环境缺口 + +- 没有登录态。没有在浏览器里登录,没有收到验证码邮件,也没有导出过报告。不写成通过。可照做的步骤在 `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 e0425670..48a13bf4 100644 --- a/docs/testing/ui-kit-migration-20261023.md +++ b/docs/testing/ui-kit-migration-20261023.md @@ -60,3 +60,30 @@ 2. 选好两个人,点开始合盘。先出现「会用掉 N 点,余额 M 点。」,按钮是取消和开始。N 是计价表上的价格,不是写死的数字。点开始之后才生成。这两人已经合过的次数,仍写在按钮下面。 3. 余额不够时,这句话改成「需要 N 点,余额只有 M 点。」,主按钮变成去补点数。点了去补点数的那一页。 4. 第一次打开一份合盘报告,两个人的头像仍会靠拢,再展开报告。系统开了减少动态效果时,只淡入,头像不飞。报告左上角回到婚恋合盘。语言仍是中文和 English。 + +## 报告 + +没有登录态,没有在浏览器里打开过报告,也没有导出过文件。 + +1. 打开一份有英文版的个人报告。语言是一排分段,读屏名是「报告语言」。点 English,正文和目录换成英文,页面上不再留着刚才的中文。再点中文,英文也不残留。 +2. 点导出。桌面从右侧滑出。把窗口改成大约 390 像素宽,从底部滑出。抽屉里没有「拿去问 AI」那句说明,按钮上也不写文件大小。整篇时按钮是「导出整篇」。点自选章节后,一块都不勾时按钮是「先勾选要导出的部分」,勾了若干块是「导出 N 项」。 +3. 打开一份没有英文版的旧报告,再点导出。会提示只能导出中文,或提示重新生成才能有中英两版。这句话不常驻在抽屉里。 +4. 在报告列表里,每一行右上角是更多。点删除会先问「确定删除这份报告?」。取消后焦点回到这个更多按钮。 +5. 读一篇较长的报告。右下角是「到底部」。快到文末时变成「回到顶部」。 + +## 星历 + +没有登录态,没有在浏览器里翻过日期。 + +1. 打开星历。日期左右是箭头,读屏名是「前一天」和「后一天」。点前一天,下面的卡片往这个方向翻。卡片四周的边框还在,没有被切掉一圈。 +2. 翻日期时没有转圈,也没有「正在加载」。看的是今天时,日期下面不出现「今天」。看的不是今天时,日期下面有「今天」。 +3. 系统开了减少动态效果时,卡片只淡入,不左右移动。 + +## 登录 + +没有登录态,没有发过验证码邮件,也没有在浏览器里登录。 + +1. 打开登录页。验证码登录和密码登录是一排分段。邮箱和按钮是普通的输入框和按钮。 +2. 走到填写六位验证码时,是六格分开的输入。恢复码仍是一条输入框。 +3. 注册账号、忘记密码、返回登录仍是这几个字。没勾同意就继续,仍会提示先阅读并勾选。 +4. 打开那一刻的天空。星星仍会汇聚到定格。左上关闭、右上分享仍在。没有重画星星。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 0b57ea72..5aa31346 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -688,7 +688,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/ ### Login consent and the legal pages (2026-09-30) -- **Login:** under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (`.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but mark the box (a `--color-danger` outline, `.auth-consent.is-attention`) and raise the error toast 「请先阅读并勾选同意,再继续。」; ticking clears the mark. The operators' admin login (password-only, `/admin`) has no box. The login page is a three-row grid (`minmax(56px, 1fr) auto minmax(56px, 1fr)`, no vertical padding; the card's min-height is `min(680px, 100dvh - 128px)`): the card sits in the middle row, exactly centred, and the 56px floors keep the footer off the card on short windows, and the caption footer 用户协议 · 隐私政策 (plus the ICP number to beian.miit.gov.cn once configured) takes the bottom row, so it never pushes the card up (BUG-1122). Below 768px the rows are `1fr auto` and the footer sits under the full-height card. +- **Login (2026-10-25, K5):** email, password and the action buttons are component-library Input / Button. A six-digit code is InputOTP (slots `size-11`). 验证码登录 / 密码登录, and 动态验证码 / 恢复码 inside two-factor, are segmented tabs. 注册账号, 忘记密码 and 返回登录 are link-styled buttons. Backup codes stay one `.otp-input` field at 20px. Under the form, an unticked checkbox 「我已阅读并同意《用户协议》和《隐私政策》」 (component-library Checkbox, `.auth-consent`, caption size, links underlined, open in a new tab). Sending a code or a password sign-in with the box unticked does nothing but mark the box (a `--color-danger` outline, `.auth-consent.is-attention`) and raise the error toast 「请先阅读并勾选同意,再继续。」; ticking clears the mark. The operators' admin login (password-only, `/admin`) has no box. The login page is a three-row grid (`minmax(56px, 1fr) auto minmax(56px, 1fr)`, no vertical padding; the card's min-height is `min(680px, 100dvh - 128px)`): the card sits in the middle row, exactly centred, and the 56px floors keep the footer off the card on short windows, and the caption footer 用户协议 · 隐私政策 (plus the ICP number to beian.miit.gov.cn once configured) takes the bottom row, so it never pushes the card up (BUG-1122). Below 768px the rows are `1fr auto` and the footer sits under the full-height card. - **Account menu:** a caption row at the very bottom, 「用户协议 · 隐私政策」, below 退出登录. - **/terms and /privacy:** public, static, one 720px column on `--color-canvas-soft`; a top row with 「返回 Jyotisha」 and a link to the other document; while the text is a draft, a warning-tinted note 「草稿 · 版本 …,待律师审定」 above the title; h1 in the display face, h2 per section, bullet lists for enumerations. - **Re-consent:** when a final version replaces the one a signed-in user accepted, one modal 「协议已更新」 with the same checkbox and 「同意并继续」; it cannot be dismissed without agreeing. Off while the documents are a draft. @@ -709,7 +709,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/ - **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`. - **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line. - **Surface:** `--color-canvas-soft` floor; completed rows are paper, other rows canvas, with a warm hairline and `--radius-lg` on the list as a whole. No drop shadow. -- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right `⋯` menu containing only 「删除」. It opens the row-local 「确定删除这份报告?」 with 删除 / 取消, never `window.confirm`. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row. +- **Metadata and deletion:** full Chinese date · localized depth · localized themes; unknown IDs remain readable verbatim. Every row has a 44px top-right menu (component-library DropdownMenu, trigger class `report-center-menu`, Lucide ellipsis, accessible name 「报告更多操作」) containing only 「删除」. It opens 「确定删除这份报告?」 (AlertDialog) with 删除 / 取消, never `window.confirm`. While the delete is in flight the confirm button reads 「删除中…」. Failure stays in that row; success removes it and invalidates list cache. In-flight GET responses cannot resurrect a deleted row. - **Width:** 900px centered, matching long-form chat reading. Rows stack at the global 767px cut. - **Actions:** “生成报告” in the generate card is the one filled action. A ready row keeps only “查看报告”; export is selected inside the reader so the list does not duplicate that action. Generating copy is one-step (“正在生成报告,大约 10–30 秒”); there is no chapter-count progress. A failed row states the reason in place and offers no second generate entry — the card above already has one. Card summary is a deterministic excerpt of the Markdown 「摘要」 section, stored on the cover document at generation time. - **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. First-screen loading uses `SecondaryPageShell` waiting copy 「报告列表还没拿到。」 — never a spinner, skeleton, or 「正在加载」. Export is local to the ready reader's drawer and never starts a second writing flow or fetch. Download failures raise an error toast and leave the drawer open; only success closes it and emits the 「已导出」 toast. A missing Markdown appendix is an old report: the detail page asks the reader to regenerate. @@ -718,19 +718,19 @@ Action results and errors never take page layout. They are toasts from `src/lib/ ### Personal report reader - **Buttons on the report surfaces:** the BUG-1078 report-only override (2026-09-28) was replaced the same day by the site-wide solid-button pair (BUG-1079, see Button); report buttons look as before. Outline buttons there (the header 「生成报告」 while submitting) now fade like outline buttons elsewhere instead of the report-only grey. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode). -- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** native modal ``, bottom sheet below 860px (up to 85dvh) and a centred `min(520px, 92vw)` panel at ≥860px, app colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); when the report has an English edition, a two-way segment 中文 / English (starts on the edition being read, does not switch the page) with one caption 「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」, otherwise no caption: opening the sheet raises one info toast (id `report-export-english`) saying why; two radio cards 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」; only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The file size appears once, on the footer button: 「导出整篇 · 约 195 KB」 / 「导出 6 项 · 约 80 KB」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch. +- **导出报告 sheet (redesigned 2026-09-30, product: 「分块导出太丑」):** a component-library Sheet. Desktop slides in from the right (`data-side="right"`, width `min(520px, 92vw)`, max 520px). Below 767px it slides up from the bottom (`data-side="bottom"`, up to 85dvh). App colours only. Top to bottom: title 「导出报告」 with a × (读屏名「关闭导出」); segmented tabs for language (`aria-label="导出语言"`, 中文 / English) only when an English edition exists. They start on the edition being read and do not switch the page. There is no AI caption. Without an English edition, opening the sheet raises one info toast (id `report-export-english`) saying why. Then two segmented tabs (`aria-label="导出范围"`) 「整篇报告」 (subline counts 章正文 / 张分盘 / 张核对表) and 「自选章节」 (the second tab also carries 「只带走需要的部分」); only in 自选, a plain checklist grouped 正文 / 分盘 / 核对表, each group one tri-state checkbox (✓ / − / empty) plus a chevron, 正文 open and the other groups folded. Rows are borderless 44px lines, no per-block character counts. The footer button does not show a file size: 「导出整篇」 / 「导出 N 项」 / disabled 「先勾选要导出的部分」. Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode). Escape, backdrop or × dismisses; focus returns without scrolling. No spinner, clipboard or extra fetch. - **Export selection:** 整篇 = every block of the chosen edition; 自选 starts with all 正文 chapters ticked. Switching the sheet's language resets the checklist to that edition's chapters (block ids differ per edition). Presets 常用 / 全部分盘 / 仅核对表 and 全选 were retired with the redesign. - **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name. - **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”. - **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill. -- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows a two-segment pill (`.report-language-switch`, 40px segments, selected = `--color-canvas-muted` + ink + 600) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」. -- **导出框里的英文版提示:** reading Chinese with an English edition available, the drawer header adds one `--color-canvas-muted` band (`.report-export-ai-hint`) with the AI sentence and an outline 「切到 English」; it stacks below 767px. Old reports get one caption line (`.report-export-edition-note`) instead. The drawer is keyed by edition, so switching resets the selection to that edition's blocks; the file name gains `-EN`. +- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」. +- **导出框里的英文版提示 (2026-10-25, K5 / D8):** the caption and the 「切到 English」 band are gone. A report without an English edition still raises one info toast (id `report-export-english`) when the sheet opens. Switching the sheet's language still resets the checklist to that edition's blocks; the file name still gains `-EN`. - **Opening a report (2026-09-30, BUG-1106):** one waiting screen, `ReportOpening` — the 46px header 「个人报告」 over a centred spinner and 「正在加载报告…」. The route's `loading.tsx` and the reader's first fetch both render it, so nothing swaps or jumps between them. -- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the description line is hidden, the count is one line 「已选 n/N 块 · 约 X KB(全篇约 Y KB)」, the presets scroll sideways in a single row, 关闭 is a × icon, and the sheet may take up to 85dvh so the block list gets most of it. +- **Phones (2026-09-30, BUG-1107):** the action bar is one row, about 53px: an arrow-only back link (screen readers still hear 「返回报告中心」), the 中文 / English pill at 36px segments, and 「导出」 at 40px. In the export sheet below 767px the sheet slides up from the bottom and may take up to 85dvh. There is no description line and no file size. 关闭 is a × icon (accessible name 「关闭导出」). - **Switching editions keeps your place (BUG-1108):** the chapter at the top of the reader is remembered; the new edition renders every chapter up to it at once and scrolls back to it. - **判断不了的分盘提示 (2026-10-01, BUG-1139):** for a segment-v1 adoption whose card left D9/D10 undetermined, the longform body carries one plain paragraph directly under each such chart heading and its 数值位置表 heading — no new component, no blockquote, no colour. Under a chart heading it becomes the chart card's caption (`report-chart-grid-rehype.ts::parseCard` accepts exactly one paragraph between heading and fence), so the chart grid never splits. Added before the snapshot hash; legacy reports are untouched. -- **到底部 / 回到顶部:** one floating pill (`.report-edge-jump`, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper. +- **到底部 / 回到顶部:** one floating control (`.report-edge-jump`, a component-library Button `variant="outline"` with class `report-edge-jump__button` and Lucide ArrowDown / ArrowUp, fixed bottom-right, same look as the chat's 跳到最新). It reads 「到底部」 until the reader is within 480px of the end, then 「回到顶部」 — never both. Sections below the fold mount lazily, so the click first mounts every section synchronously (`flushSync`) and then scrolls to `scrollHeight`, landing on the real end in one click. It is not a scroll follower and owns no anchor; it is hidden on paper. - **Long full-data reports (2026-10-08):** when the Markdown is longer than 200,000 characters and has more than one chapter, the reader shows one chapter at a time. The TOC and 「上一章 / 下一章」 (44px, screen only) change the chapter; the article carries `data-chapter-index` so a 中文 / English switch returns to the same chapter. 「到底部」 opens the last chapter. Shorter reports keep the lazy-section reader. There is no full-text search. Print still mounts every chapter. Markdown download stays on the existing 「导出」 sheet. - **TOC rail:** a persistent right-hand column built from the outline's own `##`/`###` ids — no second slugger, and nothing that touches the chart-grid rehype pass. It is sticky under the report's own action bar, highlights the section in view via `IntersectionObserver`, and marks it with `aria-current="location"` plus a 2px `--color-action` bar. Placement is explicit (`grid-column: 2`) rather than DOM-ordered, so the narrow-screen drawer can stay first in the source. Below 860px the rail is gone and what remains is the collapsed 「目录」 drawer. - **Paper stays paper:** the rail reads in the app palette because it is chrome. `--report-paper` / `--report-rule` / `--report-accent` are the document's own, deliberately out of step with the app accent, and nothing in this surface puts them on chrome or takes chrome colour onto the page. D11, continuing D3. @@ -1150,7 +1150,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 - **流式:** 折叠块保持收起,不渲染 children,副标写「正在写…」。不得出现 spinner / 骨架 / 「正在加载」。流式结束后再解析报告 markdown。口语层在流式期间仍走 `StreamingMarkdown`。 - **个人报告页**走 `personal-report-markdown-view.tsx`,不经过本组件。 - **窄屏表格:** 三列表(技法审计)继续卡片化。四列及以上(Yoga、统一参数)在 `.conversation` 里横向滚动,右缘用 canvas 渐隐提示还可滑;`.conversation` 自身 `overflow-x: hidden`,页面不得出现横向滚动条。个人报告表格不走这套规则。 -- **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。 +- **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。登录页的六位验证码是组件库 InputOTP,格子 `size-11`。恢复码仍用 `.otp-input`,并加 `h-11`。 ### Chat body lists @@ -1338,7 +1338,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` 独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」在 46px 顶栏里,顶栏不放按钮;`.ephemeris-body` 是唯一滚动的东西。 - **结构:** 日期条;今日五要素;当日行运;未来九十天;底部提问提示卡。顺序固定。页尾不另写定性句。 -- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。 +- **日期条:** 居中的日期是主语。左右是组件库 Button(`size="icon"`)加 Lucide 的 ChevronLeft / ChevronRight,字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。日期下面的五要素、行运和未来九十天按方向翻页(已有的 Motion `dateTurn`,位移 24px)。翻页容器 `overflow: visible`,四周留 1px,卡片描边不会被裁掉。系统开了减少动态效果时只淡入,不位移。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。 - **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。 - 切到未缓存的日期时,日期条和布局留下,五要素、行运立刻换成「这一天的五要素还没拿到。」「这一天的行运还没拿到。」事件区写「换座和停滞还在取。」,这时不用「还没接上」。没有 spinner、骨架或「正在加载」。请求失败时,三段都写「这一天的星历没取到,点日期再试一次。」再点这一天的日期,或在失败时点左右箭头,就重试这一天;日历仍可改去别的日期。旧日期的响应,包括进页时刷新的今天,不得盖住正在看的另一天。 - **五要素(2026-09-26 可读性改版):** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块。767px 以下收成五行:左列标签固定 5.5rem、不加字距,行距收紧到 `--space-2`。 @@ -1367,7 +1367,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` - 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(` - + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7f3890d1..9151f841 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4222,46 +4222,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-center-delete-confirm p { margin: 0; } .report-center-delete-confirm button, .report-center-menu button { min-height: 44px; } -/* Native modal: narrow screens keep the content-column sheet; desktop centers in the viewport. */ +/* Export sheet (K5): the component library slides it from the right, or from the + bottom under 768px. Width and edge live here so the panel is not the default + narrow utility width. The list scrolls; the header and footer stay. */ .report-export-drawer { - --report-export-sidebar: 0px; - position: fixed; - inset: auto 0 0 var(--report-export-sidebar); - width: min(760px, calc((100% - var(--report-export-sidebar)) * .92)); - max-width: none; - max-height: 85vh; - margin: 0 auto; - padding: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: var(--color-canvas); color: var(--color-ink); - box-shadow: var(--shadow-elevated); - overflow: hidden; } -[data-viewport="desktop"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-desktop); } -[data-viewport="tablet"][data-state="expanded"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-tablet); } -[data-viewport="desktop"][data-state="collapsed"] .report-export-drawer, -[data-viewport="tablet"][data-state="collapsed"] .report-export-drawer { --report-export-sidebar: var(--sidebar-width-icon); } -.report-export-drawer::backdrop { background: var(--color-scrim); } -.report-export-panel { display: flex; flex-direction: column; max-height: 85vh; } -@supports (height: 1dvh) { - .report-export-drawer, .report-export-panel { max-height: 85dvh; } +.report-export-drawer[data-side="right"] { + width: min(520px, 92vw); + max-width: 520px; } -/* After the narrow-screen dvh upgrade so it cannot cap desktop at 85dvh. */ -@media (min-width: 860px) { - .report-export-drawer { - inset: 0; - width: min(520px, 92vw); - max-height: 80vh; - margin: auto; - border-radius: var(--radius-lg); - } - .report-export-panel { max-height: calc(80vh - 2px); } +.report-export-drawer[data-side="bottom"] { + max-height: 85dvh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } -/* 导出报告 sheet (redesigned 2026-09-30): title row; the edition as a two-way - segment with one caption; two radio cards, 整篇报告 / 自选章节; the checklist - only in 自选; the size on the button. No bordered block cards, no presets. */ .report-export-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; } .report-export-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; } .report-export-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-5) var(--space-4); } @@ -4270,7 +4245,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-segment button { border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; } .report-export-segment button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-action); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), inset 0 0 0 1px var(--color-action-strong); } .report-export-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; } -.report-export-modes { display: grid; gap: var(--space-2); } +.report-export-modes { display: grid; grid-template-columns: 1fr 1fr; width: 100%; height: auto; } +.report-export-modes button { height: auto; white-space: normal; } +.report-export-modes strong { display: block; font-size: var(--type-body-md); font-weight: 600; } +.report-export-modes small { display: block; margin-top: 2px; color: var(--color-ink-tertiary); font-size: var(--type-caption); } .report-export-mode { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; } .report-export-mode[aria-checked="true"] { border-color: var(--color-action-strong); background: transparent; } .report-export-mode strong { display: block; font-size: var(--type-body-md); font-weight: 600; } @@ -6577,6 +6555,18 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } font-size: var(--type-title-md); line-height: 1; } +/* The turn sits under the date bar. Padding keeps the 1px card border outside + the grid cell so a sliding page does not clip it. No overflow clip. */ +.ephemeris-turn { + display: grid; + overflow: visible; + margin: -1px; + padding: 1px; +} +.ephemeris-turn-page { + grid-area: 1 / 1; + min-width: 0; +} /* Only rendered off today, so it never needs a disabled face. */ .ephemeris-today { min-height: 44px; diff --git a/frontend/src/components/birth-sky/birth-sky-replay.tsx b/frontend/src/components/birth-sky/birth-sky-replay.tsx index c1517241..ca83d939 100644 --- a/frontend/src/components/birth-sky/birth-sky-replay.tsx +++ b/frontend/src/components/birth-sky/birth-sky-replay.tsx @@ -3,6 +3,8 @@ import { Share, X } from "lucide-react"; import { useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; + import { BirthSkyConverge } from "./birth-sky-converge"; import type { BirthSkyCover } from "@/lib/birth-sky/cover"; @@ -44,13 +46,13 @@ export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClo onDone={onClose} controls={(
- - + +
)} /> diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index b84f4aa6..911d9521 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -4,6 +4,11 @@ import Image from "next/image"; import { FormEvent, useState } from "react"; import { LOGIN_CONSENT_HINT, LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { recordLegalConsent } from "@/lib/legal-consent-client"; import { dismissNotice, notifyError, notifyInfo, notifySuccess, NOTICE_DURATION_MS } from "@/lib/notify"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; @@ -29,6 +34,28 @@ export function offersFirstPasswordStep(testMode: boolean, mode: AuthMode): bool return !(testMode && mode === "otp"); } +/** Six boxes for an email or authenticator code. Backup codes stay a normal field. */ +function EmailCodeField({ id, value, onValue }: { id: string; value: string; onValue: (value: string) => void }) { + return ( + onValue(next.replace(/\D/g, "").slice(0, 6))} + > + + {[0, 1, 2, 3, 4, 5].map((index) => ( + + ))} + + + ); +} + function clearAuthNotices() { dismissNotice(AUTH_NOTICE); dismissNotice(AUTH_ERROR); @@ -360,38 +387,33 @@ export function EmailOtpLogin({ {showLoginNavigation && ( )} {showBackToLogin && ( )} @@ -401,9 +423,10 @@ export function EmailOtpLogin({ className="stack-form auth-step" onSubmit={signInWithPassword} > - - 邮箱 + - - 密码 + - + ) : step === "email" ? (
- - 邮箱 + - +
) : step === "two-factor" ? (
-
- - -
- - { - const value = mfaMethod === "totp" - ? event.target.value.replace(/\D/g, "").slice(0, 6) - : event.target.value.trimStart().slice(0, 128); - setToken(value); + { + if (value !== "totp" && value !== "backup-code") return; + setMfaMethod(value); + setToken(""); dismissNotice(AUTH_ERROR); }} - /> - + ) : step === "otp" && mode === "forgot" ? (
- - 邮箱验证码 + { - setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); + onValue={(next) => { + setToken(next); dismissNotice(AUTH_ERROR); }} /> - - 新密码 + - - 确认新密码 + - +
- - + +
) : step === "otp" ? (
- - 邮箱验证码 + { - setToken(event.target.value.replace(/\D/g, "").slice(0, 6)); + onValue={(next) => { + setToken(next); dismissNotice(AUTH_ERROR); }} /> - +
- - + +
) : step === "set-password" ? ( @@ -634,9 +640,10 @@ export function EmailOtpLogin({ className="stack-form auth-step" onSubmit={saveFirstPassword} > - - 设置密码 + - - 确认密码 + - + ) : (
- +
)} diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index 29136494..e094627a 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -1,9 +1,9 @@ "use client"; import { AppLink } from "@/components/app-link"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; -import { CalendarIcon } from "lucide-react"; +import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import { zhCN } from "date-fns/locale"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -35,6 +35,8 @@ import { transitHouseLine, } from "@/lib/ephemeris-view"; import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; +import { loadDomAnimation, loadMotionReact, peekMotionReact, type MotionReact } from "@/lib/motion/load-react"; +import { motionPresets } from "@/lib/motion/presets"; import { PLANET_COLOR_CLASS, PLANET_GLYPH, planetGlyphKey } from "@/lib/planet-glyphs"; type PageState = @@ -260,11 +262,8 @@ export function EphemerisView(props: {
- {/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles. - Three equal side-by-side buttons made 前一天 / 今天 / 后一天 read as - three peers and pushed the date itself out of the group entirely. - The arrow glyphs are decorative — the direction words stay as the - accessible names, so nothing is lost to a screen reader. */} + {/* The date is the subject. Lucide arrows are its handles. + The direction words stay as the accessible names. */}
{/* The date itself opens a month grid. Stepping a day at a time was the only way to reach another date, so anything more than a few days @@ -322,7 +321,7 @@ export function EphemerisView(props: { aria-label={EPHEMERIS_COPY.nextDay} onClick={props.onNext} > - +
{/* On today there is nowhere to go, so the control is absent rather than @@ -335,6 +334,7 @@ export function EphemerisView(props: { )}
+

{EPHEMERIS_COPY.panchangaHeading}

{failed ? ( @@ -428,7 +428,77 @@ export function EphemerisView(props: { {EPHEMERIS_COPY.ask} +
); } + +function useMotionReact(): MotionReact | null { + const [mod, setMod] = useState(() => peekMotionReact()); + useEffect(() => { + if (mod) return; + let live = true; + void loadMotionReact().then((next) => { + if (live) setMod(next); + }, () => undefined); + return () => { + live = false; + }; + }, [mod]); + return mod; +} + +/** Page turn under the date bar. The date bar itself stays put. + * Until Motion has loaded, the sections paint in place. Cards are not clipped. */ +function EphemerisTurn({ date, children }: { readonly date: string; readonly children: ReactNode }) { + const motion = useMotionReact(); + const [frame, setFrame] = useState(() => ({ date, direction: 1, animated: false })); + if (date !== frame.date) { + const direction = date > frame.date ? 1 : -1; + setFrame({ date, direction, animated: motion !== null }); + } + if (!frame.animated || motion === null) { + return
{children}
; + } + return ( + + {children} + + ); +} + +function EphemerisPresence({ + motion, + date, + direction, + children, +}: { + readonly motion: MotionReact; + readonly date: string; + readonly direction: number; + readonly children: ReactNode; +}) { + const reduced = motion.useReducedMotion() ?? false; + const preset = motionPresets.dateTurn; + const distance = reduced ? 0 : 24; + const duration = reduced ? preset.reduced.duration : preset.duration; + return ( + +
+ + + {children} + + +
+
+ ); +} diff --git a/frontend/src/components/legal/legal-consent-gate.tsx b/frontend/src/components/legal/legal-consent-gate.tsx index 7f814be0..5cbcb4f0 100644 --- a/frontend/src/components/legal/legal-consent-gate.tsx +++ b/frontend/src/components/legal/legal-consent-gate.tsx @@ -1,9 +1,11 @@ "use client"; import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { CURRENT_LEGAL_VERSION, LEGAL_RECONSENT_BLOCKING } from "@/lib/legal-consent"; import { recordLegalConsent } from "@/lib/legal-consent-client"; import { notifyError } from "@/lib/notify"; @@ -21,7 +23,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { const [required, setRequired] = useState(false); const [accepted, setAccepted] = useState(false); const [busy, setBusy] = useState(false); - const dialog = useRef(null); useEffect(() => { if (!LEGAL_RECONSENT_BLOCKING || !signedIn || checkedVersion === CURRENT_LEGAL_VERSION) return; @@ -34,10 +35,6 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { return () => { cancelled = true; }; }, [signedIn]); - useEffect(() => { - if (required) dialog.current?.showModal(); - }, [required]); - if (!required) return null; async function agree() { @@ -45,28 +42,26 @@ export function LegalConsentGate({ signedIn }: { signedIn: boolean }) { setBusy(true); const ok = await recordLegalConsent(); setBusy(false); - if (ok) { - dialog.current?.close(); - setRequired(false); - } else { - notifyError("没能记录,请稍后再试。"); - } + if (ok) setRequired(false); + else notifyError("没能记录,请稍后再试。"); } return ( - event.preventDefault()}> - -

我们更新了《用户协议》和《隐私政策》(版本 {CURRENT_LEGAL_VERSION})。请阅读后确认,以便继续使用。

- - -
+ undefined} disablePointerDismissal> + + +

我们更新了《用户协议》和《隐私政策》(版本 {CURRENT_LEGAL_VERSION})。请阅读后确认,以便继续使用。

+ + +
+
); } diff --git a/frontend/src/components/legal/login-consent.tsx b/frontend/src/components/legal/login-consent.tsx index ef46f178..7e803a1b 100644 --- a/frontend/src/components/legal/login-consent.tsx +++ b/frontend/src/components/legal/login-consent.tsx @@ -1,5 +1,8 @@ +"use client"; + import Link from "next/link"; +import { Checkbox } from "@/components/ui/checkbox"; import { LEGAL_ENTITY } from "@/lib/legal-entity"; export const LOGIN_CONSENT_HINT = "请先阅读并勾选同意,再继续。"; @@ -16,7 +19,7 @@ export function LoginConsentField({ checked, hint, onChange }: { return (