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
+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