fix(settings): 弹窗高度补 vh 基线,分区菜单去掉强调条(BUG-698)
Independent Staging Quality Gate / validate (push) Failing after 9m25s
Independent Staging Quality Gate / publish (push) Skipped

设置弹窗的固定高度只用 dvh 写、没有回退。不认识该单位的引擎会把整条
height 与 max-height 作废,盒子退回按内容撑开,于是切分区就跳大小——
这正是 BUG-554 现象的复发,而 BUG-554 的防复发「必须同时声明 width 与
height」只检查声明存不存在,挡不住「写了但没生效」。

实测(Chrome 151,真实产物 CSS + 复刻 DOM,1440×900):dvh 正常时四个
分区恒定 866.80×640px,**事故不复现**;摘掉 dvh 后变成 313/313/378/1130,
宽度不动——与用户描述的形状完全一致。因此机制已证实,但用户当时的浏览器
未定位,BUG-698 记为 investigating 而非 resolved。

附带发现:任务书要求照抄的重复声明式回退 `height: 100vh; height: 100dvh;`
在本仓根本发布不出去——Lightning CSS 会合并同名属性的重复声明只留最后一条,
全仓唯一那处回退(sidebar-provider)在线上早就是死的,还有一条测试专门守着
这个从未发布过的写法。改用 @supports (height: 1dvh):vh 作基线,dvh 作升级。
修复后不支持 dvh 的引擎也收敛到恒定 640px,支持的逐像素无变化。

同轮按产品决策去掉设置分区菜单的左侧/下方强调色条,选中与悬停改用面与
墨色等级区分,不用色相、不用字重。左侧会话列表的色条本轮不动。

- 新增 viewport-unit-fallback-contract(3 条,全文件),三次破坏性验证各自打红
- account-dialog-overlay 新增同尺寸契约与分区菜单契约
- 三条钉死旧 dvh 字面量的既有断言按「原值/新值/原因」更新,均未弱化
- tsc 0 错;lint 0 error / 118 warning(持平);npm test 3346/3300/fail 31,
  失败清单与基线逐字相同;/ 仍 ○ Static;样式 gzip +0.38%;
  快速门 pytest 段 792 passed / 1 skipped / 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-16 03:50:38 +00:00
co-authored by Claude Opus 5
parent 5582091851
commit 111b4a8455
11 changed files with 538 additions and 20 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-16 — 设置弹窗不再随分区改变大小,分区菜单去掉左侧色条
设置弹窗的四个分区(个人资料 / 星盘资料 / 账户与点数 / 通用设置)现在在任何浏览器上都保持同一个外框大小,切换分区时只有右侧内容在换。此前固定高度只用了较新的 `dvh` 单位写,不认识这个单位的浏览器会把整条高度丢掉,弹窗就退回按内容撑开——内容多的分区高、内容少的分区矮,看起来就是「选别的就立马缩小了」。同一处写法在登录页、后台页面和手机侧栏也一并修好。
分区菜单去掉了当前项左侧的竖色条(手机上是下边框)。当前项改用更实的底色加更深的字来表示,鼠标划过是更淡的一层底,两者一眼能分清,文字不加粗。左侧会话列表里当前会话的色条保持不变。
Skill 版本不变(纯界面改动)。
## 2026-09-16 — 出生记录和校正结果对不上时,说清默认按哪个排盘
录入时选了「医院记录」,而校正算出的范围不含记录上那一分钟时,交付卡以前只说一句相差多少分钟。现在会写满三层:默认仍按你的出生记录时间排盘、你说的经历指向另一段时间相差多少、你可以改用校正结果也可以继续用记录。这种情况下卡上的按钮从「更像这个」改成「改用校正结果」,按钮下面写明「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。记录落在范围内、或来源是「家人记得的大概时间 / 只知道时段」时,卡片和以前完全一样。系统不会说你的记录不准,也不会说校正结果不作数。Skill 版本不变(只补 references 规则)。
+21 -1
View File
@@ -8576,7 +8576,7 @@
- 根因:`accountDialogClasses` 给每个分区不同宽度,高度随内容撑开。星盘资料没有列表/详情视图,添加表单始终渲染。第四个导航项直接 `router.push``/membership`
- 修复:四个分区共用 `.settings-modal` 固定尺寸。星盘资料改为列表→详情。账户与点数成为弹窗第四分区;删除 `/membership``/membership/orders`,旧链接重定向到 `/?settings=billing`。七处入口改为 `openAccountDialog("billing")`
- 验证:`frontend/tests/account-dialog-overlay.test.ts``billing-panel.test.ts``membership-page.test.ts``membership-navigation-contract.test.ts``chart-library-other-profile.test.ts``settings-url.test.ts``beam-avatar.test.ts``class-name-definition-contract.test.ts``tests/test_api_server_security.py -k capability_audit``next build --webpack``/` 为 Static,无 `/membership` 页面。
- 防复发:设置弹窗必须同时声明 width 与 height;星盘资料默认视图不得无条件渲染 `<form``/` 保持 Static,不得 `useSearchParams`;收银台仍 `window.open`;硬跳转清单不得因本单变长。
- 防复发:设置弹窗必须同时声明 width 与 height;星盘资料默认视图不得无条件渲染 `<form``/` 保持 Static,不得 `useSearchParams`;收银台仍 `window.open`;硬跳转清单不得因本单变长。**2026-09-16 修正,见 BUG-698**:「必须同时声明 width 与 height」这条防不住复发——它检查的是声明存在性,不是声明是否生效。`.settings-modal` 后来 width 与 height 都老实写着,却因为 height 只用 `dvh`、不认识该单位的引擎整条作废而再次随分区跳变。固定高度的单位回退口径以 BUG-698 为准。
- 相关记录:BUG-434、BUG-251、BUG-143
- 复发自:无
- 修复版本:`dc6598d7`
@@ -11477,3 +11477,23 @@
- 相关记录:BUG-729731(创建路由改造)、BUG-464(会话持久化归属)
- 复发自:无
- 修复版本:待发布
## BUG-698 | 设置弹窗尺寸随分区变化:dvh 无回退,且仓库唯一那处回退被压缩器吃掉
- 状态:investigating
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`frontend/src/app/globals.css``.settings-modal` / `.account-modal` 及全文件 8 处 `height: …dvh`)、设置弹窗四分区、登录页、后台外壳、移动端侧栏
- 用户现象:「设置面板的弹窗大小不一样,选别的就立马缩小了。」宽度没动,高度在跳。
- 触发条件:登录后打开设置弹窗,在个人资料 / 星盘资料 / 账户与点数 / 通用设置之间切换。
- 根因(机制已证实,用户环境未定位):`.settings-modal``height``max-height` 只写了 `dvh`,没有 `vh` 回退。不认识该单位的引擎会**整条声明作废**,`height` 回到 `auto`,高度改由内容决定;`max-height` 同时作废后连上限都没有,于是内容多的分区高、内容少的分区矮。宽度走 `vw` 不受影响,所以看上去只有高度在跳。
- **实测(Chrome 151.0.7922.1691440×900,真实 `next build` 产物 CSS + 逐节点复刻的弹窗 DOM)**:dvh 正常时四个分区全是 866.80 × 630.40、computed height 640px —— **事故在当前浏览器上不复现**;把 dvh 摘掉后四个分区变成 313.23 / 313.23 / 378.58 / 1130.16,宽度不变 —— **与用户描述的形状完全一致**
- `dvh` 自 Chrome/Edge 108、Safari 15.4、Firefox 101、Android WebView 108 起支持,因此该机制只在旧 iOS 15.015.3、未升级的 Android System WebView、或钉死旧 WebView 的 App 内置浏览器上成立。**用户当时用的是哪个浏览器尚未确认,这是本条停在 `investigating` 而不是 `resolved` 的唯一原因。**
- 为什么 BUG-554 的防复发没拦住:BUG-554 的条款是「设置弹窗必须同时声明 width 与 height」,守着它的断言是 `assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/)` —— 它读 CSS 源文本,只检查**声明存不存在**,不检查声明**是否生效**。本次 width 与 height 都老实写着,条款全绿,现象照出。**BUG-554 的四个分区共用一个类这一条本轮未被推翻,且新增了直接断言常量的同尺寸契约把它钉死。**
- 附带发现(比原判更严重):任务书要求照抄 `globals.css:637` 的重复声明式回退 `height: 100vh; height: 100dvh;`。实测 **Lightning CSSTailwind v4 的压缩器)会合并同一规则内同名属性的重复声明,只保留最后一条**:源码写了两条,产物里 `.group\/sidebar-provider[data-viewport]` 只剩 `height:100dvh`。**全仓唯一那处 vh 回退在线上早就是死的**,而且 `birth-time-mobile-scroll-contract.test.ts` 还有一条断言专门守着这个从未发布过的写法。照任务书交付等于交一个 no-op。
- 修复:改用特性查询 —— `vh` 作基线,`@supports (height: 1dvh)` 内升级为 `dvh`,压缩器无法证伪条件因而整块保留。覆盖凡 `height`(不含 `max-height` / `min-height`)用 dvh 的 8 处:`.standalone-page``.app-loading``.group\/sidebar-provider[data-viewport]``.admin-app-shell``.auth-page``[data-sidebar="sidebar"]``.settings-modal`(桌面与移动端);另把 `.account-modal` / `.settings-modal``max-height` / `min-height` 一并纳入,因为实测里 `max-height` 塌成 `none` 也是尺寸失控的一环。同轮去掉设置分区菜单的强调色条并拆开选中/悬停两态(产品决策,不占编号)。
- 验证:修复后同 harness 重测 —— dvh 正常的引擎四个分区仍是 866.80 × 630.40 / 640px**与修复前逐像素一致,无回归**);模拟不支持 dvh 的引擎从 313/378/1130 收敛到恒定 640px。新增 `frontend/tests/viewport-unit-fallback-contract.test.ts`(3 条,全文件范围),三次破坏性验证各自打红对应那条、还原后全绿。`account-dialog-overlay.test.ts` 新增同尺寸契约与分区菜单契约,破坏性验证同样打红。`tsc --noEmit` 0 错,`npm run lint` 0 error / 118 warning(持平基线),`next build``/``○ Static`,快速门 pytest 段 792 passed / 1 skipped / 0 failed。
- 防复发:**`height``dvh` 必须写成 `vh` 基线 + `@supports (height: 1dvh)` 升级,不得用 `height: 100vh; height: 100dvh;` 重复声明式回退**——后者会被 Lightning CSS 吃掉,从来没发布过。由 `frontend/tests/viewport-unit-fallback-contract.test.ts` 全文件强制,三条断言分别管「dvh 必须在特性查询内」「禁止重复声明式回退」「升级过的选择器必须有 vh 基线」。设置弹窗四分区必须映射到同一个类名,由 `account-dialog-overlay.test.ts` 直接断言常量。**只检查「声明存不存在」的 CSS 文本断言不算防线**(这就是 BUG-554 的教训),新写 CSS 契约要么断到实际产物、要么断到可被破坏性验证证伪的结构。
- 相关记录:BUG-554(同一现象的第一次,本条为其复发)、BUG-695~697(触控与断点轮次,同一文件相邻区段)
- 复发自:BUG-554
- 修复版本:待发布
@@ -0,0 +1,222 @@
# PROGRESS · 设置弹窗尺寸(BUG-698)+ 分区菜单去强调条
- 日期:2026-09-16
- 任务书:`docs/tasks/TASK-settings-dialog-size-and-nav-20260915.md`
- 分支:`codex/settings-dialog-size-and-nav-20260916`
- 工作树:`.worktrees/settings-dialog-size-and-nav-20260916`
- 实际基线:`origin/staging` @ `5094fd26`(任务书写的 `6c748d86` 已被十余个提交超车;三条前提逐条复核仍成立,见 §1)
- BUG 编号:`docs/BUG_HISTORY.md` 当前最大号 **BUG-736**`BUG-698` 未被占用,按任务书使用 `BUG-698`,未顺延。
---
## 1. 开工前置:前提仍然成立
| 任务书断言 | `5094fd26` 实测 | 结论 |
|---|---|---|
| `accountDialogClasses` 四分区全是 `"settings-modal"` | `home-types.ts:211-217` 四项均为 `settings-modal` | 成立 |
| `.settings-modal` 同时写死 width 与 height | `globals.css:1757``width: min(100vw - 32px, 880px); height: min(84dvh, 640px)` | 成立 |
| 桌面强调条 `inset 2px 0 0 var(--color-action)` | `globals.css:1762` | 成立 |
| 移动端强调条 `inset 0 -2px 0 var(--color-action)` | `globals.css:1948` | 成立 |
| 选中态与悬停态共用一条规则 | `globals.css:1761` | 成立 |
| BUG-554 防复发「必须同时声明 width 与 height」 | 仍在,且仍绿 | 成立(本轮不推翻) |
`AccountDialogOverlay` 四个分区的 DOM 结构完全一致(`account-dialog-overlay.tsx:76-94`),`dialog``model.dialogClass` 在同一次 render 里都来自 `activeAccountDialog``page.tsx:1510-1512``1827-1831`),不存在切换瞬间掉类的中间帧。**所以外框尺寸只可能由 CSS 决定。**
## 2. 任务 1.1 复现:有浏览器,有数字
会话没有登录态,但本机有 `google-chrome 151.0.7922.169`。做法是绕开登录、直接量 CSS:把 `next build` 产出的**真实 chunk CSS**`.next/static/chunks/*.css`)和按 `account-dialog-overlay.tsx` 逐节点复刻的 DOM 组成离线页面,四个分区各塞 6 / 24 / 3 / 1 段内容模拟真实内容量差,headless Chrome 量 `section.account-modal.settings-modal``getBoundingClientRect()` 与 computed style。窗口 1440×900。
harness`gen.js` + `supported.html` / `nodvh.html`scratchpad,未提交)。
### 2.1 基线 `5094fd26`dvh 正常的引擎(Chrome 151
| 分区 | rect w | rect h | computed height | computed max-height |
|---|---:|---:|---|---|
| 个人资料 | 866.80 | 630.40 | 640px | 640px |
| 星盘资料 | 866.80 | 630.40 | 640px | 640px |
| 账户与点数 | 866.80 | 630.40 | 640px | 640px |
| 通用设置 | 866.80 | 630.40 | 640px | 640px |
rect 630.40 < computed 640 是 `account-dialog-enter` 入场动画的 transform,四个分区同值,不影响结论。)
**四个分区宽高完全一致,`height` 声明没有被划掉,计算值不是 `auto`。按任务书 §5 任务 1.1 的分支判断,事故在当前浏览器上不复现。**
### 2.2 同一份 CSS,去掉 dvh 支持
把 chunk 里所有 dvh 声明摘掉,模拟不认识该单位的引擎:
| 分区 | rect w | rect h | computed height | computed max-height |
|---|---:|---:|---|---|
| 个人资料 | 866.80 | 378.58 | 384.344px | none |
| 星盘资料 | 866.80 | 1130.16 | 1147.38px | none |
| 账户与点数 | 866.80 | 313.23 | 318px | none |
| 通用设置 | 866.80 | 313.23 | 318px | none |
**宽度纹丝不动(走 `vw`),高度随内容量在 313 / 378 / 1130 之间跳。** 这正是用户原话「大小不一样,选别的就立马缩小了」的形状。任务书 §2.2 推测的机制被证实为**唯一**能产生该现象的 CSS 路径,但它以「引擎不认识 dvh」为前提。
### 2.3 结论与诚实边界
- `dvh` 的支持面是 Chrome/Edge 1082022-11)、Safari 15.42022-03)、Firefox 1012022-05)、Android WebView 108。**任何当前浏览器都支持**,所以产品负责人若在新版 Chrome / Safari 上看到该现象,本机制解释不了。
- 仍然可能命中的环境:iOS 15.015.3、未升级的 Android System WebView< 108)、以及部分把旧 WebView 钉死的 App 内置浏览器(微信/QQ 等)。对一个中文消费产品这不是零概率,但**本轮无法证明用户就在其中**。
- 因此 BUG-698 记为 **`investigating`**,不是 `resolved`。机制已确认、已加固,但用户实际环境未定位。
## 3. 任务书的修复方案不成立(本轮最重要的发现)
任务书 §1.2 让我们照抄 `globals.css:637` 的写法,补成重复声明:
```css
height: min(84vh, 640px);
height: min(84dvh, 640px);
```
**这个写法在本仓根本到不了浏览器。** 先按任务书实现了一版,`next build` 后 grep 产物:
```
.settings-modal{...;width:min(100vw - 32px,880px);height:min(84dvh,640px);max-height:min(84dvh,640px);...}
```
`vh` 那条不见了。再去查任务书引为范本的那一行:
- 源码 `globals.css:637``.group\/sidebar-provider[data-viewport] { height: 100vh; height: 100dvh; ... }`
- 产物:`sidebar-provider[data-viewport]{height:100dvh;min-height:0;overflow:hidden}`
**Lightning CSSTailwind v4 的压缩器)会合并同一规则内同名属性的重复声明,只保留最后一条。** 也就是说:全仓唯一那处「标准写法」的 vh 回退,在线上**早就是死的**;任务书若原样交付,等于交一个 no-op。
改用特性查询,Lightning CSS 无法证伪条件,整块原样保留:
```css
.settings-modal { width: min(100vw - 32px, 880px); height: min(84vh, 640px); max-height: min(84vh, 640px); ... }
@supports (height: 1dvh) {
.settings-modal { height: min(84dvh, 640px); max-height: min(84dvh, 640px); }
}
```
产物核对(`@supports` 完整保留):
```
@supports (height:1dvh){.standalone-page,.app-loading,.group\/sidebar-provider[data-viewport],.admin-app-shell,.auth-page{height:100dvh}.account-modal{max-height:min(84dvh,760px)}.settings-modal{height:min(84dvh,640px);max-height:min(84dvh,640px)}}
@supports (height:1dvh){[data-sidebar=sidebar]{height:100dvh}.account-modal{min-height:100dvh;max-height:100dvh}.settings-modal{height:100dvh;max-height:100dvh}}
```
### 3.1 修复后重测(同 harness,同窗口)
| 引擎 | 四个分区 rect | computed height |
|---|---|---|
| dvh 正常(Chrome 151 | 866.80 × 630.40 ×4,全等 | 640px ×4 |
| 去掉 `@supports` 块(模拟不支持 dvh | 866.80 × 630.40 ×4,全等 | 640px ×4 |
**支持 dvh 的浏览器行为与修复前逐像素一致(无回归);不支持 dvh 的引擎从 313/378/1130 的跳变收敛到恒定 640px。**
覆盖范围:凡 `height`(不含 `max-height` / `min-height`)用 dvh 的 8 处全部改为 vh 基线 + `@supports` 升级 —— `.standalone-page``.app-loading``.group\/sidebar-provider[data-viewport]``.admin-app-shell``.auth-page``[data-sidebar="sidebar"]``.settings-modal`(桌面与移动端)。另把 `.account-modal` / `.settings-modal``max-height` / `min-height` 一并纳入,因为 §2.2 实测里 `max-height` 塌成 `none` 也是尺寸失控的一环。其余只写 `max-height` 的(`.session-actions-positioner``.account-menu-popup``.auth-shell``.select-content``.select-list`)按任务书不动 —— 作废后只是少一个上限,不会让盒子随内容跳变。
`.admin-app-shell``globals.css` 里,属于布局类而非 antd / Refine 组件,改的是同一条 dvh 规则,不触碰任务书 §4.5 的 admin 红线。
### 3.2 自查抓到一个自己引入的回归(已修)
第一版把全部 7 个选择器的 dvh 升级都放进**顶层** `@supports`。复查 diff 时发现 `.auth-page` 的高度基线只存在于 `@media (max-width: 767px)` 内(`globals.css:1952`),顶层那条 `.auth-page { display: grid; place-items: center; padding: … }` 本来**没有高度**。放到顶层等于给桌面登录页新加了一个满视口高度约束 —— 桌面登录页原本是随内容高的。已把 `.auth-page` 的升级挪进移动端作用域内的 `@supports`
**新增的契约测试没能抓到这个**:它只验「文件里某处存在 vh 基线」,不验基线与升级是否在同一个 at-rule 作用域。这条限制已写进测试文件头部注释,提醒后来者手工对齐作用域。其余 6 个选择器的基线都在顶层,作用域本就一致,逐个复核过。
## 4. 任务 1.3 防复发:从「写没写」升级到「量不量得到」
新增 `frontend/tests/viewport-unit-fallback-contract.test.ts`3 条,全文件范围):
1. `height:` 用 dvh 的声明必须落在 `@supports (height: 1dvh)` 内。
2. 禁止重复声明式回退 —— 它会被压缩器吃掉,测试里写明了原因。
3. `@supports` 里升级过的每个选择器,块外必须有 vh 基线(否则不支持的引擎干脆没有高度)。
解析前先剥 CSS 注释:本文件自己的说明注释里引用了 `height: 100vh; height: 100dvh;` 这个坏写法,不剥会自伤(首次运行就被这条打红,已修)。
**破坏性验证**(三次,跑完都还原,未提交):
| 破坏 | 预期 | 实测 |
|---|---|---|
| `.settings-modal` 基线换回 dvh | 第 1 条红 | `not ok 1`2/3 绿 |
| `.admin-app-shell` 改回 `height: 100vh; height: 100dvh;` | 第 2 条红 | `not ok 1` + `not ok 2` |
| 删掉 `.auth-page` 的 vh 基线只留 `@supports` | 第 3 条红 | `not ok 3`1/2 绿 |
| 还原 | 全绿 | `ok 1 / ok 2 / ok 3` |
`account-dialog-overlay.test.ts` 新增 2 条:
- 同尺寸契约:`accountDialogClasses` 四个设置分区映射到同一个类名(直接 import 常量断言,不再只验单个分区的渲染),且 `logout` 与之不同。
- 分区菜单契约:`[aria-current="page"]``:hover` 规则内都不含 `box-shadow`、不含 `font-weight`,且两者必须是各自独立的规则块。破坏性验证:把强调条与合并规则塞回去 → `not ok 5`,还原 → `ok 5`
原有 `assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/)` **未改动**(新 CSS 仍然匹配),只在上方加注释说明它是存在性检查、真正的防线是上述两处。**本轮没有修改任何既有断言,所以不涉及「原值 / 新值 / 原因」三栏。**
## 5. 任务 2:分区菜单
改动(`globals.css`):
```css
/* 前 */
.settings-dialog-nav-item:hover, .settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
.settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 2px 0 0 var(--color-action); }
/* 移动端 */ .settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-action); }
/* 后 */
.settings-dialog-nav-item:hover { background: color-mix(in srgb, var(--color-canvas-muted) 55%, transparent); color: var(--color-ink-secondary); }
.settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
/* 移动端那条整条删除 */
```
- 三态可分:默认透明 + 次级墨 → 悬停 55% 淡面 + 次级墨 → 选中实面 + 主墨。选中比悬停重一档,靠面与墨色等级,不用色相、不用 `font-weight`(任务书 §3.3)。
- 取 55% 的理由:`--color-canvas-muted` 浅色 `#ebe9e3` 压在 `--color-canvas` `#fbfaf7` 上本就只差一档,悬停若取满会和选中撞;55% 是仓库里已有的同族用法(`globals.css` 现有 84 处 `color-mix`,如 `.evidence-audit-panel` 用 68%、`.evidence-audit-row` 用 64%),**没有引入新字面色值、没有新 token**。
- 深色:`--color-canvas-muted` `#30302d``--color-ink` `#f2f0ea``--color-ink-secondary` `#b3afa4` 三者在深色块里都已定义,改动只用既有 token,`dark-theme-contract.test.ts` 无需新增条目(实跑仍绿)。深浅两套的肉眼可分辨留给真人清单。
- 移动端(≤767px)导航是顶部四栏,触摸没有 hover,选中态即实面 + 主墨,删掉下边框后仍与其余三项可分。
- 可达性:`:focus-visible` 描边规则未动;`aria-current="page"` 仍在 DOM 上,屏幕阅读器不受影响 —— 选中态不是只靠颜色。
- 左侧会话列表的 2px `--sidebar-ring` **本轮未动**(任务书 §3.2),两处观感暂时不一致,已在 `DESIGN.md` 写明是已知且授权的。
## 6. 验收
| 项 | 基线 `5094fd26` | 本轮 | 结论 |
|---|---|---|---|
| `tsc --noEmit` | 0 | 0 | 通过(中途新测试有 1 个 TS2345,已修) |
| `npm run lint` | 0 error / 118 warning | 0 error / 118 warning | 通过,warning 未上升 |
| `npm test` | tests 3341 / pass 3295 / fail 31 / skipped 15 | tests 3346 / pass 3300 / fail 31 / skipped 15 | 通过,失败清单与基线**逐字相同** |
| `next build` `/` | `○ Static` | `○ Static` | 通过 |
| 样式 gzip | 37,547 B | 37,688 B | +141 B / **+0.38%**,在 ±2% 内 |
| 快速门 pytest 段 | 792 passed / 1 skipped / 0 failed | 792 passed / 1 skipped / 0 failed | 通过 |
| `Home()` useState / useRef | 36 / 37 | 36 / 37(未碰 `page.tsx` | 未增长 |
| `page.tsx` 行数 | 1846 | 1846(未改动) | 未增长 |
gzip 口径:Next 16 的路由表不再打印 First Load JS,本轮又只改 CSS,所以量的是产物样式 chunk 本身——把新增的两个 `@supports` 块摘掉再 gzip 作对照(37,547 B),带上是 37,688 B。
本轮只动 `globals.css``DESIGN.md` 与五个测试文件,`page.tsx` 与任何 Home 级 hook 都没碰,所以没有新增 Home 级 hook,不涉及 `OPTIONAL_HOME_HOOKS` 登记(BUG-736)。
### 6.1 全量套件
`tests` 3341 → **3346**+5,正是本轮新增的 5 条),`pass` 3295 → **3300**+5),`fail` 31 → **31**`skipped` 15 → 15。测试总数没有下降。
失败清单与基线 `diff` 结果为**完全一致**(31 条,全是无 Docker 的 DB / 部署 / 引擎套件,`BLOCKED.md` BLK-002 / BLK-003):
```
comm -13 fails-baseline.txt fails-final.txt # 新增失败:空
comm -23 fails-baseline.txt fails-final.txt # 消失失败:空
diff -q fails-baseline.txt fails-final.txt # IDENTICAL
```
**中途出现过 3 条新失败,已修,原因记在 §6.1.1。**
#### 6.1.1 三条既有断言按「原值 / 新值 / 原因」改动
改 dvh 写法打红了三条把旧文本钉死的断言。三条都**保留原有意图(满视口高度 / 全视口滚动容器)**,只把断言对象换成真正会发布的写法,没有一条被弱化或删除:
| 文件 | 原值 | 新值 | 原因 |
|---|---|---|---|
| `tests/birth-time-mobile-scroll-contract.test.ts` | `/\.group\\\/sidebar-provider\[data-viewport\]\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh/` | 拆成两条:块内 `height:\s*100vh;`,外加 `@supports (height: 1dvh)` 内该选择器 `height: 100dvh` | **这条原本守着一个从未发布过的写法。** 重复声明式回退被 Lightning CSS 合并,产物里只剩 `height:100dvh`;断言读的是源码文本所以一直绿。换成断两半后,vh 基线与 dvh 升级都必须真在。 |
| `tests/mobile-interaction-contract.test.ts` | `css.indexOf(".auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto;")` | 同串改 `height: 100vh`,并**新增**一条断 `@supports` 内有 `.auth-page { height: 100dvh; }` | 登录页的满视口高度改为 vh 基线 + 特性查询升级。原断言只认旧字面量。断言数量从 1 增到 2,未放宽。 |
| `tests/admin-payments-contract.test.ts` | `/\.admin-app-shell \{ height: 100dvh; min-height: 0; overflow-y: auto; \}/` | 同串改 `height: 100vh`,并**新增**一条断 `@supports` 内有 `.admin-app-shell { height: 100dvh; }` | 同上。后台外壳仍是独立全视口滚动容器,只是高度写法换了。断言数量从 1 增到 2,未放宽。 |
### 6.2 快速门
`.venv/bin/python scripts/run_quality_gate.py --profile quick`**pytest 段 792 passed / 1 skipped / 0 failed**,与基线一致。
快速门整体退出 1,唯一原因是本机无 `rsync`、无 Docker 的既有环境缺口(`BLOCKED.md` BLK-002 / BLK-003),与本轮改动无关。退出码直接取自命令本身,没有隔着管道。
## 7. 环境缺口
1. **无登录态、无法在真实应用里走查。** §2 的数字是在真实产物 CSS + 逐节点复刻 DOM 的离线页面上量的,不是在跑起来的应用里量的。真实内容(星盘资料的列表/详情、账户与点数的套餐卡)可能有本 harness 没模拟到的溢出行为。浏览器级条目见 `docs/testing/settings-dialog-20260916.md`
2. **无法确认用户当时的浏览器。** 这是 BUG-698 停在 `investigating` 的唯一原因。需要产品负责人补:哪两个分区、什么浏览器与版本、窗口多大、能否截图。
3. 无 DockerDB / 部署套件 31 条失败沿用基线(`BLOCKED.md` BLK-002 / BLK-003),与基线逐条比对见 §6.1。
4. 深浅两套主题下「悬停 vs 选中」肉眼可分辨、以及 375px 下四栏观感,都需要真人确认。
+66
View File
@@ -0,0 +1,66 @@
# 真人验收清单 · 设置弹窗尺寸与分区菜单(BUG-698 + 分区菜单产品决策)
- 日期:2026-09-16
- 分支:`codex/settings-dialog-size-and-nav-20260916`
- 对应:`docs/tasks/TASK-settings-dialog-size-and-nav-20260915.md``docs/tasks/PROGRESS-settings-dialog-size-and-nav-20260916.md`
会话侧已经量过的(**不用再做**):真实产物 CSS + 复刻 DOM 的离线页面上,四个分区在支持与不支持 `dvh` 两种引擎下都恒定 866.80 × 640px。下面全是会话做不了的:需要登录态、真实内容、真实设备、以及人眼。
---
## A. 尺寸:四个分区切换不跳变
1. 登录后打开设置弹窗。
2. 依次点 个人资料 → 星盘资料 → 账户与点数 → 通用设置 → 再点回 个人资料。
3. **看外框四条边有没有动。** 尤其是底边:星盘资料内容最多、通用设置最少,是最容易露馅的一对。
4. 通过标准:外框纹丝不动,只有右侧内容区在换;左侧 176px 导航与标题栏不动。
在这几种窗口下各走一遍:
| 场景 | 怎么弄 | 预期 |
|---|---|---|
| 桌面常规 | 浏览器最大化 | 宽 880px 上限,高 640px,四分区全等 |
| 矮窗口 | 把窗口高度拖到 600px 左右 | 高度变成 84% 视口高,**四分区仍全等** |
| 窄窗口 | 宽度拖到 900px 以下 | 宽度跟着收,四分区仍全等 |
| 手机宽度 375px | DevTools 设备模拟 或 真机 | 弹窗全屏,导航变顶部四栏,切换不跳 |
5. **星盘资料分区要展开到最满再切**:进列表 → 点开某个人的详情 → 再切到 通用设置。内容最多的一刻切走,是本 Bug 最容易复现的时机。
## B. 尺寸:旧引擎(本 Bug 的真正目标环境)
本条是**定位用**的,不是回归用的。BUG-698 停在 `investigating` 就是因为没人知道用户当时用的什么浏览器。
1. 产品负责人回忆并回答:出现「选别的就立马缩小」时,用的是**哪个浏览器、哪个版本、什么设备**?是电脑浏览器,还是微信 / QQ 里点开的?窗口多大?能否截图?
2. 若手边有 iOS 15.015.3 的旧 iPad / iPhone,或较老的安卓机(System WebView 低于 108),在上面走一遍 A 的第 1–4 步。
- 修复前:高度会随分区跳(这就是用户看到的)。
- 修复后:应当恒定。
3. 若产品负责人当时用的是**新版 Chrome / Safari**,那么本轮的修复解释不了那次现象,需要带着截图另开一轮 —— 请如实回报,不要因为现在看着正常就当作已修。
## C. 分区菜单:三态可分辨
1. 打开设置弹窗,鼠标**不要**碰导航,看当前分区那一项:应是**实心浅底 + 深墨字**。
2. 鼠标划过另外三项:应出现**比选中更淡的一层底**,字仍是次级墨(比选中浅一档)。
3. 通过标准:
- **没有任何左侧竖色条**(这是本轮产品要求删掉的东西)。
- 选中项与悬停项**一眼能分出谁是当前**:选中的底更实、字更黑。
- 字**没有变粗**(产品明确不要加粗)。
4. 把鼠标移到当前分区上(同时是选中 + 悬停):不应闪烁或变得比不悬停时更淡。
## D. 分区菜单:深色主题
1. 系统或应用切到深色,重复 C 的 1–4。
2. 通过标准:深色下悬停那层淡底**仍然看得见**(这是 55% 混透明,深色底上对比最容易不够,是本轮最需要人眼确认的一点);选中仍比悬停重一档。
3. 若深色下悬停几乎看不出来,如实回报,把 55% 调高即可 —— 不要改成加色条或加粗。
## E. 移动端(≤767px
1. 375px 宽下打开设置弹窗:导航是顶部四栏。
2. 通过标准:当前那一栏是实心底 + 深墨字;**底部没有横向色条**(本轮删掉的);四栏等宽,点哪栏切哪栏。
3. 触摸设备没有 hover,所以这里只需确认「当前」这一态够明显。
## F. 不该被碰到的(回归检查)
1. 左侧会话列表里当前会话的 **2px 竖色条仍在** —— 本轮只删设置弹窗的,会话列表那条产品说了不动。两处观感暂时不一致是已知且授权的。
2. 键盘 Tab 走到分区按钮上,**焦点描边仍在**。
3. 退出登录确认弹窗仍是 400px 的小框,没被撑成大弹窗。
4. 登录页、后台页面(`/admin`)、移动端侧栏抽屉的满屏高度正常 —— 本轮一并改了它们的 dvh 写法。
+6 -3
View File
@@ -57,7 +57,9 @@ a fourth needs a reason:
a transparent row (`--color-action`, deepening to `--color-action-hover`).
Never a filled block: a full-width terracotta surface would break the
ninety-percent-light rule.
2. The 2px bar on the active session (`--sidebar-ring`).
2. The 2px bar on the active session (`--sidebar-ring`). This is the sidebar only —
the settings dialog's pane menu carries no accent bar and separates hover from
current with surface and ink rank instead.
3. The profile initial's circle (`--color-action`), which only renders when the
account has no uploaded avatar.
@@ -170,7 +172,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- Desktop shell: 288px sidebar plus flexible reading panel.
- Layout breakpoints: mobile below 768px, tablet 7681023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail. CSS `@media` width cuts must follow `sidebarViewportForWidth` in `frontend/src/lib/sidebar-state.ts` (768 / 1024), not the other way around. The allowlist lives as a comment at the top of `globals.css` and is locked by `frontend/tests/viewport-breakpoint-contract.test.ts`.
- Allowed `@media` widths: 480 (small phone), 640/641 (content grids: membership, rectification candidates, intake card), 767/768 (mobile/tablet), 860 (report TOC, content width of the third column), 1023/1024 (tablet/desktop). Do not add a new width without updating that list and the contract test in the same change.
- All full-height surfaces use `100dvh`. Touch targets: on a coarse pointer the hit is 44×44; the visual control may stay smaller. Message-action icons stay 26×26; a fine pointer keeps the 27×34 overlay, a coarse pointer opens gap and bottom margin to 20px so a 44×44 overlay does not eat the next button or the follow-up pills. Locked by `frontend/tests/touch-target-contract.test.ts`.
- All full-height surfaces use `100dvh`, but never as the only declaration. A `dvh` length is dropped whole by an engine that does not know the unit, and a `height` that vanishes falls back to the content height — which is how the settings dialog started resizing per pane (BUG-698). Write the plain `vh` value as the base and upgrade inside `@supports (height: 1dvh)`. Do **not** use the duplicate-declaration form `height: 100vh; height: 100dvh;`: Lightning CSS (Tailwind v4's minifier) collapses duplicate declarations of one property and keeps only the last, so the fallback never reaches the browser. Locked by `frontend/tests/viewport-unit-fallback-contract.test.ts`. Touch targets: on a coarse pointer the hit is 44×44; the visual control may stay smaller. Message-action icons stay 26×26; a fine pointer keeps the 27×34 overlay, a coarse pointer opens gap and bottom margin to 20px so a 44×44 overlay does not eat the next button or the follow-up pills. Locked by `frontend/tests/touch-target-contract.test.ts`.
## 5. Components
@@ -392,7 +394,8 @@ Text release is paced, not animated: the frame buffer commits at most once per a
### Settings dialog
- **Structure:** one fixed chrome for four panes — 个人资料, 星盘资料, 账户与点数, 通用设置. Left nav is 176px and does not scroll; the title bar stays put; only the right-hand content pane scrolls. Logout stays a separate 400px confirmation.
- **Width / height:** desktop `width: min(100vw - 32px, 880px); height: min(84dvh, 640px)`. Switching panes does not change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top.
- **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top.
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
- **Personal profile:** one row of avatar editing (48px preview, eight palettes, 换一个形象) plus nickname and login email. No duplicate 管理星盘资料 button.
- **Chart library:** list first (self row, other rows, 添加其他人). A row opens a detail with ← 星盘资料. Other details own 设为默认 / 删除 / 用于合盘 and that person's synastry history. The add form is a view, not an always-on stack.
- **States:** open, pane switch, list / self / other / add, saving, success, and error.
+36 -13
View File
@@ -528,7 +528,7 @@ button:disabled { cursor: default; opacity: .45; }
.form-error { background: var(--color-danger-muted); color: var(--color-danger); }
.form-success { background: var(--color-success-muted); color: var(--color-success); }
.standalone-page { width: 100%; height: 100dvh; overflow: auto; }
.standalone-page { width: 100%; height: 100vh; overflow: auto; }
.auth-brand strong { font-weight: 600; }
.auth-step { transition: opacity 180ms ease-out, transform 180ms var(--ease-out); }
@starting-style { .auth-step { opacity: 0; transform: translateY(6px); } }
@@ -627,14 +627,14 @@ button:disabled { cursor: default; opacity: .45; }
[data-sidebar="rail"]:focus-visible { outline-width: 4px; }
}
.app-loading { width: 100%; height: 100dvh; display: grid; place-items: center; padding: 24px; background: var(--color-canvas); }
.app-loading { width: 100%; height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--color-canvas); }
.app-loading-content strong { margin-top: 10px; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; letter-spacing: -.3px; }
.app-loading-content > span { max-width: 360px; line-height: 1.55; font-size: var(--type-body-sm); }
.app-loading-orbit { position: absolute; inset: 0; border: 1px solid var(--color-border); border-radius: 50%; animation: app-loading-orbit 1.4s linear infinite; border-color: var(--color-canvas-strong); }
.app-loading-error { background: var(--color-canvas-soft); }
.app-loading-error .app-loading-content { padding: 28px; border: 1px solid var(--color-border); background: var(--color-canvas); border-color: var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-elevated); }
.group\/sidebar-provider[data-viewport] { height: 100vh; height: 100dvh; min-height: 0; overflow: hidden; }
.group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; }
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
[data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); }
@@ -1753,13 +1753,13 @@ button:disabled { cursor: default; opacity: .45; }
[role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { opacity: 0; transform: translateX(var(--space-1)); }
.account-modal-overlay { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: var(--space-4); background: var(--color-scrim); animation: account-overlay-enter 180ms ease-out both; }
.account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
.settings-modal { width: min(100vw - 32px, 880px); height: min(84dvh, 640px); max-height: min(84dvh, 640px); overflow: hidden; display: grid; grid-template-rows: auto 1fr; }
.account-modal { width: min(100%, 560px); max-height: min(84vh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
.settings-modal { width: min(100vw - 32px, 880px); height: min(84vh, 640px); max-height: min(84vh, 640px); overflow: hidden; display: grid; grid-template-rows: auto 1fr; }
.account-settings-shell { display: grid; grid-template-columns: 176px minmax(0, 1fr); min-height: 0; overflow: hidden; align-items: stretch; }
.settings-dialog-nav { display: grid; align-content: start; gap: var(--space-1); overflow: hidden; }
.settings-dialog-nav-item { min-height: 44px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 16px; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; }
.settings-dialog-nav-item:hover, .settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
.settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 2px 0 0 var(--color-action); }
.settings-dialog-nav-item:hover { background: color-mix(in srgb, var(--color-canvas-muted) 55%, transparent); color: var(--color-ink-secondary); }
.settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
.settings-dialog-nav-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); }
.settings-dialog-nav-item > svg:last-child { width: 15px; height: 15px; margin-left: auto; }
.settings-dialog-content { min-width: 0; min-height: 0; overflow-y: auto; }
@@ -1879,7 +1879,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.admin-page { background: var(--color-canvas-soft); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
.admin-app-shell { height: 100vh; min-height: 0; overflow-y: auto; }
.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; }
.code-form > * { min-width: 0; }
.section-title h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; letter-spacing: -.3px; }
@@ -1913,7 +1913,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
/* The glass surface assumes a light backdrop. On mobile the drawer floats
above the scrim instead, which darkens and flattens it, so the drawer
takes the solid token same treatment as prefers-reduced-transparency. */
[data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100dvh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
[data-sidebar="sidebar"] { background: var(--sidebar-solid); backdrop-filter: none; position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--sidebar-width-mobile); height: 100vh; padding: max(var(--space-3), env(safe-area-inset-top)) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); border-right: 1px solid var(--sidebar-border); }
[data-sidebar="sidebar"][data-mobile-open="false"] { visibility: hidden; transform: translateX(-100%); }
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
@@ -1939,18 +1939,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.select-content { max-height: min(320px, 56dvh); }
.select-list { max-height: min(288px, 48dvh) !important; }
.account-modal-overlay { padding: 0; }
.account-modal { width: 100%; max-height: 100dvh; min-height: 100dvh; border-radius: 0; padding: var(--space-6); }
.settings-modal { width: 100%; height: 100dvh; max-height: 100dvh; }
.account-modal { width: 100%; max-height: 100vh; min-height: 100vh; border-radius: 0; padding: var(--space-6); }
.settings-modal { width: 100%; height: 100vh; max-height: 100vh; }
.account-settings-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; gap: var(--space-4); }
.settings-dialog-nav { position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border); }
.settings-dialog-nav-item { min-height: 40px; grid-template-columns: 1fr; justify-items: center; gap: 2px; padding: var(--space-2); text-align: center; font-size: var(--type-caption); }
.settings-dialog-nav-item > svg:last-child { display: none; }
.settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-action); }
.settings-dialog-content { min-height: 0; overflow-y: auto; }
.avatar-editor { grid-template-columns: 48px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 48px !important; height: 48px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; }
.auth-page { height: 100vh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; }
.auth-shell { min-height: 100%; overflow: visible; align-content: start; grid-template-columns: 1fr; grid-template-rows: auto auto; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 0; justify-content: flex-start; gap: var(--space-3); padding: var(--space-6) var(--space-6) var(--space-5); }
.auth-story h2 { max-width: 560px; margin: var(--space-3) 0 0; font-size: var(--type-display-sm); }
@@ -5106,3 +5105,27 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.ephemeris-events li { grid-template-columns: 1fr; gap: var(--space-1); }
.ephemeris-ask { width: 100%; }
}
/* Dynamic viewport units, applied only where the engine understands them.
Written as a feature query rather than the classic `height: 100vh; height: 100dvh;`
duplicate-declaration fallback: Lightning CSS (Tailwind v4's minifier) collapses
duplicate declarations of the same property inside one rule and keeps only the last,
so the duplicate form never reaches the browser and the vh fallback is silently lost.
See BUG-698; locked by frontend/tests/viewport-unit-fallback-contract.test.ts. */
@supports (height: 1dvh) {
.standalone-page { height: 100dvh; }
.app-loading { height: 100dvh; }
.group\/sidebar-provider[data-viewport] { height: 100dvh; }
.admin-app-shell { height: 100dvh; }
.account-modal { max-height: min(84dvh, 760px); }
.settings-modal { height: min(84dvh, 640px); max-height: min(84dvh, 640px); }
}
@media (max-width: 767px) {
@supports (height: 1dvh) {
[data-sidebar="sidebar"] { height: 100dvh; }
.auth-page { height: 100dvh; }
.account-modal { max-height: 100dvh; min-height: 100dvh; }
.settings-modal { height: 100dvh; max-height: 100dvh; }
}
}
@@ -8,6 +8,7 @@ import {
AccountDialogOverlay,
type AccountOverlayModel,
} from "../src/components/account-dialog-overlay.tsx";
import { accountDialogClasses } from "../src/lib/home-types.ts";
function overlayModel(overrides: Partial<AccountOverlayModel> = {}): AccountOverlayModel {
const overlayRef = createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"];
@@ -85,6 +86,42 @@ test("settings navigation lists four panes and billing can be current", () => {
assert.match(html, /账户与点数/);
assert.match(html, /通用设置/);
assert.match(html, /aria-current="page"/);
// Existence check only, kept from BUG-554. It cannot see whether the declaration
// survives to the browser, which is exactly how BUG-698 slipped through: the height
// was declared but written only in dvh. The real guards are the same-size contract
// below and frontend/tests/viewport-unit-fallback-contract.test.ts.
assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/);
assert.doesNotMatch(styles, /chart-library-modal|profile-modal/);
});
test("all four settings panes share one dialog class, so the box cannot change size", () => {
// BUG-554 root cause: each pane had its own width class. BUG-698 is the same symptom
// from a different layer, so the shared-class invariant is asserted directly rather
// than inferred from one pane's rendered output.
const panes = ["profile", "chart-library", "billing", "general"] as const;
const classes = new Set(panes.map((pane) => accountDialogClasses[pane]));
assert.deepEqual([...classes], ["settings-modal"]);
assert.notEqual(accountDialogClasses.logout, accountDialogClasses.profile);
});
test("the settings pane menu separates hover from current without an accent bar", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const navRules = (styles.match(/[^{}\n]*\.settings-dialog-nav-item[^{}]*\{[^{}]*\}/g) ?? []);
const currentRules = navRules.filter((rule) => rule.includes('[aria-current="page"]'));
const hoverRules = navRules.filter((rule) => rule.includes(":hover"));
assert.ok(currentRules.length > 0, "expected a rule for the current settings pane");
assert.ok(hoverRules.length > 0, "expected a rule for the hovered settings pane");
// Product decision 2026-09-15: the pane menu drops the action-colour bar.
for (const rule of [...currentRules, ...hoverRules]) {
assert.doesNotMatch(rule, /box-shadow/, `settings pane menu must not draw an accent bar: ${rule}`);
assert.doesNotMatch(rule, /font-weight/, `current pane must not be expressed with weight: ${rule}`);
}
// Hover and current must not share one declaration, or they become indistinguishable
// once the bar is gone.
for (const rule of currentRules) assert.ok(!rule.includes(":hover"), `current and hover must be separate rules: ${rule}`);
for (const rule of hoverRules) assert.ok(!rule.includes('[aria-current="page"]'), `current and hover must be separate rules: ${rule}`);
});
@@ -65,6 +65,8 @@ test("旧套餐写 API 已删除,不再假成功写脱节表", () => {
test("后台使用独立的全视口纵向滚动容器而不修改全局聊天溢出边界", () => {
assert.match(adminApp, /<div className="admin-app-shell">[\s\S]*<ConfigProvider/);
assert.match(globalsCss, /html, body \{[^}]*overflow: hidden;/);
assert.match(globalsCss, /\.admin-app-shell \{ height: 100dvh; min-height: 0; overflow-y: auto; \}/);
// BUG-698: vh base, dvh upgrade behind a feature query (see viewport-unit-fallback-contract).
assert.match(globalsCss, /\.admin-app-shell \{ height: 100vh; min-height: 0; overflow-y: auto; \}/);
assert.match(globalsCss, /@supports \(height: 1dvh\)[\s\S]*?\.admin-app-shell \{ height: 100dvh; \}/);
assert.match(globalsCss, /\.admin-app-shell > \*, \.admin-app-shell \.ant-layout \{ min-height: 100%; \}/);
});
@@ -12,5 +12,11 @@ test("mobile rectification welcome content starts at the scroll origin", () => {
assert.ok(mobileOverride > centeredGrid, "mobile display:block must be declared after the desktop grid rule");
assert.match(css, /\.conversation\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/);
assert.match(css.slice(mobileOverride), /\.conversation\.is-empty\s*\{[^}]*-webkit-overflow-scrolling:\s*touch/);
assert.match(css, /\.group\\\/sidebar-provider\[data-viewport\]\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh/);
// BUG-698: this used to assert the duplicate-declaration fallback
// `height: 100vh; height: 100dvh;`. Lightning CSS collapses duplicate declarations of
// one property and keeps only the last, so that form never reached the browser - the
// guard was passing on source text that did not ship. The shipping form is a vh base
// plus an @supports upgrade, and both halves are asserted here.
assert.match(css, /\.group\\\/sidebar-provider\[data-viewport\]\s*\{[^}]*height:\s*100vh;/);
assert.match(css, /@supports \(height: 1dvh\)[\s\S]*?\.group\\\/sidebar-provider\[data-viewport\] \{ height: 100dvh; \}/);
});
@@ -6,8 +6,12 @@ const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "ut
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
test("mobile login can scroll and keeps the form reachable on a short screen", () => {
const mobileAuth = css.indexOf(".auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto;");
// BUG-698: the full-viewport height is now a vh base upgraded inside
// @supports (height: 1dvh). Same shipped behaviour on every engine that knows dvh,
// and a real height on the engines that do not.
const mobileAuth = css.indexOf(".auth-page { height: 100vh; overflow-x: hidden; overflow-y: auto;");
assert.ok(mobileAuth >= 0, "the login page must be the mobile scroll container");
assert.match(css, /@supports \(height: 1dvh\)[^}]*\{[\s\S]*?\.auth-page \{ height: 100dvh; \}/);
assert.match(css.slice(mobileAuth, mobileAuth + 900), /\.auth-shell \{ min-height: 100%; overflow: visible;/);
assert.match(css, /@media \(max-width: 767px\) and \(max-height: 640px\) \{\s*\.auth-story \{ display: none; \}/);
assert.match(layout, /interactiveWidget:\s*"resizes-content"/);
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// BUG-698. A fixed `height` written only in `dvh` is dropped whole by any engine that
// does not know the unit, and the box silently falls back to its content height.
//
// The classic guard — `height: 100vh; height: 100dvh;` in one rule — does NOT work in this
// repo: Lightning CSS (Tailwind v4's minifier) collapses duplicate declarations of the same
// property inside a rule and keeps only the last, so the `vh` line never reaches the browser.
// Measured on the emitted chunk: `.group/sidebar-provider[data-viewport]` shipped as
// `height:100dvh` alone even though the source carried the duplicate fallback.
//
// So the contract is: `dvh` heights live inside `@supports (height: 1dvh)`, and the plain
// `vh` value is the base. `max-height` / `min-height` are deliberately out of scope — when
// those are dropped the box merely loses a cap, it does not change size per content.
// Comments are removed first: this file's own explanatory comment quotes the broken
// `height: 100vh; height: 100dvh;` form, and the scanner must not read it as real CSS.
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "");
const SUPPORTS_DVH = /@supports\s*\(\s*height\s*:\s*1dvh\s*\)\s*\{/g;
function stripSupportsDvhBlocks(source: string): string {
const kept: string[] = [];
let cursor = 0;
SUPPORTS_DVH.lastIndex = 0;
for (let match = SUPPORTS_DVH.exec(source); match; match = SUPPORTS_DVH.exec(source)) {
kept.push(source.slice(cursor, match.index));
let depth = 1;
let index = match.index + match[0].length;
while (depth > 0 && index < source.length) {
if (source[index] === "{") depth += 1;
else if (source[index] === "}") depth -= 1;
index += 1;
}
cursor = index;
SUPPORTS_DVH.lastIndex = index;
}
kept.push(source.slice(cursor));
return kept.join("");
}
// Declarations of the `height` property exactly - not max-height, not min-height.
const HEIGHT_DECLARATION = /(?:^|[;{])\s*height\s*:\s*([^;}]+)/g;
function heightValues(source: string): string[] {
const values: string[] = [];
HEIGHT_DECLARATION.lastIndex = 0;
for (let match = HEIGHT_DECLARATION.exec(source); match; match = HEIGHT_DECLARATION.exec(source)) {
values.push(match[1].trim());
}
return values;
}
test("every dvh height is guarded by a feature query", () => {
const outsideSupports = stripSupportsDvhBlocks(css);
const unguarded = heightValues(outsideSupports).filter((value) => value.includes("dvh"));
assert.deepEqual(
unguarded,
[],
`height declarations using dvh must sit inside @supports (height: 1dvh); found: ${unguarded.join(" | ")}`,
);
});
test("the duplicate-declaration fallback is not used, because the minifier strips it", () => {
const rules = css.match(/[^{}]+\{[^{}]*\}/g) ?? [];
const doubled = rules.filter((rule) => {
const values = heightValues(rule);
return values.some((value) => value.includes("dvh")) && values.some((value) => !value.includes("dvh") && value.includes("vh"));
});
assert.deepEqual(
doubled,
[],
`Lightning CSS keeps only the last of duplicate declarations, so this fallback never ships. Use @supports (height: 1dvh) instead. Found: ${doubled.join(" | ")}`,
);
});
// Known limitation: this checks that a vh base exists somewhere in the file, not that it
// sits in the same at-rule scope. `.auth-page` only has a height inside
// @media (max-width: 767px), so its dvh upgrade has to be nested in that media query too —
// putting it at top level would newly constrain the desktop login page, and this test would
// not catch it. Match the scope of the base rule by hand when adding a selector here.
test("every selector upgraded to dvh keeps a vh base outside the feature query", () => {
const outsideSupports = stripSupportsDvhBlocks(css);
const guardedSelectors = new Set<string>();
SUPPORTS_DVH.lastIndex = 0;
for (let match = SUPPORTS_DVH.exec(css); match; match = SUPPORTS_DVH.exec(css)) {
let depth = 1;
let index = match.index + match[0].length;
const start = index;
while (depth > 0 && index < css.length) {
if (css[index] === "{") depth += 1;
else if (css[index] === "}") depth -= 1;
index += 1;
}
const body = css.slice(start, index - 1);
for (const rule of body.match(/[^{}]+\{[^{}]*\}/g) ?? []) {
const [selector, declarations] = rule.split("{");
if (!heightValues(`{${declarations}`).some((value) => value.includes("dvh"))) continue;
for (const one of selector.split(",")) guardedSelectors.add(one.trim());
}
SUPPORTS_DVH.lastIndex = index;
}
assert.ok(guardedSelectors.size > 0, "expected at least one selector inside @supports (height: 1dvh)");
const missingBase: string[] = [];
for (const selector of guardedSelectors) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// `(?![\w-])` stops `.account-modal` from matching `.account-modal-overlay`.
const baseRules: string[] = outsideSupports.match(new RegExp(`${escaped}(?![\\w-])[^{}]*\\{[^{}]*\\}`, "g")) ?? [];
const declaresVhHeight = baseRules.some((rule) =>
heightValues(rule).some((value) => value.includes("vh") && !value.includes("dvh")));
if (!declaresVhHeight) missingBase.push(selector);
}
assert.deepEqual(
missingBase,
[],
`these selectors get a dvh height but no vh base, so an engine without dvh has no height at all: ${missingBase.join(" | ")}`,
);
});