() 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}`);
+});
diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts
index a4da2913..4c7d10bb 100644
--- a/frontend/tests/admin-payments-contract.test.ts
+++ b/frontend/tests/admin-payments-contract.test.ts
@@ -65,6 +65,8 @@ test("旧套餐写 API 已删除,不再假成功写脱节表", () => {
test("后台使用独立的全视口纵向滚动容器而不修改全局聊天溢出边界", () => {
assert.match(adminApp, /[\s\S]* \*, \.admin-app-shell \.ant-layout \{ min-height: 100%; \}/);
});
diff --git a/frontend/tests/birth-time-mobile-scroll-contract.test.ts b/frontend/tests/birth-time-mobile-scroll-contract.test.ts
index 1b90e629..93be4330 100644
--- a/frontend/tests/birth-time-mobile-scroll-contract.test.ts
+++ b/frontend/tests/birth-time-mobile-scroll-contract.test.ts
@@ -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; \}/);
});
diff --git a/frontend/tests/mobile-interaction-contract.test.ts b/frontend/tests/mobile-interaction-contract.test.ts
index 5a3bd9d3..7be2c30e 100644
--- a/frontend/tests/mobile-interaction-contract.test.ts
+++ b/frontend/tests/mobile-interaction-contract.test.ts
@@ -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"/);
diff --git a/frontend/tests/viewport-unit-fallback-contract.test.ts b/frontend/tests/viewport-unit-fallback-contract.test.ts
new file mode 100644
index 00000000..2c7a6da2
--- /dev/null
+++ b/frontend/tests/viewport-unit-fallback-contract.test.ts
@@ -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();
+
+ 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(" | ")}`,
+ );
+});