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