设置弹窗的固定高度只用 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
128 lines
5.5 KiB
TypeScript
128 lines
5.5 KiB
TypeScript
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(" | ")}`,
|
|
);
|
|
});
|