fix(settings): 弹窗高度补 vh 基线,分区菜单去掉强调条(BUG-698)
设置弹窗的固定高度只用 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:
co-authored by
Claude Opus 5
parent
5582091851
commit
111b4a8455
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-16 — 设置弹窗不再随分区改变大小,分区菜单去掉左侧色条
|
||||
|
||||
设置弹窗的四个分区(个人资料 / 星盘资料 / 账户与点数 / 通用设置)现在在任何浏览器上都保持同一个外框大小,切换分区时只有右侧内容在换。此前固定高度只用了较新的 `dvh` 单位写,不认识这个单位的浏览器会把整条高度丢掉,弹窗就退回按内容撑开——内容多的分区高、内容少的分区矮,看起来就是「选别的就立马缩小了」。同一处写法在登录页、后台页面和手机侧栏也一并修好。
|
||||
|
||||
分区菜单去掉了当前项左侧的竖色条(手机上是下边框)。当前项改用更实的底色加更深的字来表示,鼠标划过是更淡的一层底,两者一眼能分清,文字不加粗。左侧会话列表里当前会话的色条保持不变。
|
||||
|
||||
Skill 版本不变(纯界面改动)。
|
||||
|
||||
## 2026-09-16 — 出生记录和校正结果对不上时,说清默认按哪个排盘
|
||||
|
||||
录入时选了「医院记录」,而校正算出的范围不含记录上那一分钟时,交付卡以前只说一句相差多少分钟。现在会写满三层:默认仍按你的出生记录时间排盘、你说的经历指向另一段时间相差多少、你可以改用校正结果也可以继续用记录。这种情况下卡上的按钮从「更像这个」改成「改用校正结果」,按钮下面写明「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。记录落在范围内、或来源是「家人记得的大概时间 / 只知道时段」时,卡片和以前完全一样。系统不会说你的记录不准,也不会说校正结果不作数。Skill 版本不变(只补 references 规则)。
|
||||
|
||||
+21
-1
@@ -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-729~731(创建路由改造)、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.169,1440×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.0–15.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 CSS(Tailwind 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 108(2022-11)、Safari 15.4(2022-03)、Firefox 101(2022-05)、Android WebView 108。**任何当前浏览器都支持**,所以产品负责人若在新版 Chrome / Safari 上看到该现象,本机制解释不了。
|
||||
- 仍然可能命中的环境:iOS 15.0–15.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 CSS(Tailwind 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. 无 Docker:DB / 部署套件 31 条失败沿用基线(`BLOCKED.md` BLK-002 / BLK-003),与基线逐条比对见 §6.1。
|
||||
4. 深浅两套主题下「悬停 vs 选中」肉眼可分辨、以及 375px 下四栏观感,都需要真人确认。
|
||||
@@ -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.0–15.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
@@ -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 768–1023px, 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.
|
||||
|
||||
@@ -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(" | ")}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user