diff --git a/CHANGELOG.md b/CHANGELOG.md index de182a19..b4263b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 设置弹窗不再随分区改变大小,分区菜单去掉左侧色条 + +设置弹窗的四个分区(个人资料 / 星盘资料 / 账户与点数 / 通用设置)现在在任何浏览器上都保持同一个外框大小,切换分区时只有右侧内容在换。此前固定高度只用了较新的 `dvh` 单位写,不认识这个单位的浏览器会把整条高度丢掉,弹窗就退回按内容撑开——内容多的分区高、内容少的分区矮,看起来就是「选别的就立马缩小了」。同一处写法在登录页、后台页面和手机侧栏也一并修好。 + +分区菜单去掉了当前项左侧的竖色条(手机上是下边框)。当前项改用更实的底色加更深的字来表示,鼠标划过是更淡的一层底,两者一眼能分清,文字不加粗。左侧会话列表里当前会话的色条保持不变。 + +Skill 版本不变(纯界面改动)。 + ## 2026-09-16 — 出生记录和校正结果对不上时,说清默认按哪个排盘 录入时选了「医院记录」,而校正算出的范围不含记录上那一分钟时,交付卡以前只说一句相差多少分钟。现在会写满三层:默认仍按你的出生记录时间排盘、你说的经历指向另一段时间相差多少、你可以改用校正结果也可以继续用记录。这种情况下卡上的按钮从「更像这个」改成「改用校正结果」,按钮下面写明「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。记录落在范围内、或来源是「家人记得的大概时间 / 只知道时段」时,卡片和以前完全一样。系统不会说你的记录不准,也不会说校正结果不作数。Skill 版本不变(只补 references 规则)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 16bf425e..33edf60a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8576,7 +8576,7 @@ - 根因:`accountDialogClasses` 给每个分区不同宽度,高度随内容撑开。星盘资料没有列表/详情视图,添加表单始终渲染。第四个导航项直接 `router.push` 到 `/membership`。 - 修复:四个分区共用 `.settings-modal` 固定尺寸。星盘资料改为列表→详情。账户与点数成为弹窗第四分区;删除 `/membership` 与 `/membership/orders`,旧链接重定向到 `/?settings=billing`。七处入口改为 `openAccountDialog("billing")`。 - 验证:`frontend/tests/account-dialog-overlay.test.ts`、`billing-panel.test.ts`、`membership-page.test.ts`、`membership-navigation-contract.test.ts`、`chart-library-other-profile.test.ts`、`settings-url.test.ts`、`beam-avatar.test.ts`、`class-name-definition-contract.test.ts`、`tests/test_api_server_security.py -k capability_audit`。`next build --webpack` 中 `/` 为 Static,无 `/membership` 页面。 -- 防复发:设置弹窗必须同时声明 width 与 height;星盘资料默认视图不得无条件渲染 ` 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; } + } +} diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 7327e56a..1a6ed18b 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -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 { const overlayRef = createRef() 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(" | ")}`, + ); +});