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,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(" | ")}`,
);
});