fix(ui): 六个全屏遮罩压不住同屏 chrome,共享弹窗原语也在其中

产品裁决 2026-09-07:「那都修掉」。

## 缺陷

画了背景的全屏遮罩做出的承诺是「我后面的东西都被压暗且失效」。这六个都压不住:

| 文件 | 选择器 | 改前 | 改后 |
|---|---|---|---|
| `packages/components/src/dialog.module.css` | `.backdrop` | 100 | **1500** |
| `styles/home/plugin-marketplace-demo.css` | `.plugin-marketplace__modal-backdrop` | 60 | 1000 |
| `styles/home/home-hero.css` | `.home-hero-confirm__backdrop` | 80 | 1000 |
| `styles/home/tasks.css` | `.automation-modal-backdrop` | 80 | 1000 |
| `styles/home/plugins-view.css` | `.plugins-import-modal__backdrop` | 100 | 1000 |
| `styles/workspace/drawer.css` | `.connector-drawer-backdrop` | 100 | 1000 |

同屏参照:输入框固定层 45、`.workspace-tabs-chrome` 120、右上角那簇真机量到 150、
同侪模态 `.staged-preview-modal` 1200。

## 分档不是拍的

普查了全应用 36 个全屏遮罩,发现**应用里本来就有一档「应用模态」= 1000**
(`BrandPickerModal` / `FigmaImportModal` / `LibraryPreviewModal` / `LibraryUploadModal` /
`NewBrandModal` 五个 `.backdrop` 都在这一档)。五个全局遮罩放进**已有的档**,
它们各自独占一屏,没有谁需要压过另一张模态,1200/1500 是越权。

**共享原语是唯一的例外,1500 是推导出来的**:
- 它没有自己的屏 —— 从 `EntryShell` 和 `ProjectView` 两处渲染,而且**会嵌在别的模态里**:
  `NewProjectPanel` 的删除确认活在 `NewProjectModal` 的遮罩(920)里,100 时**那个确认框的
  遮罩画在了它自己的父弹窗底下**。
- 数值来自同一个闸门的另一条腿:`AmrOwnerTopUpDialog` 与 `AmrBalanceDialog` 是**同一个
  三元表达式的两条腿**(同一个余额闸门,只按账单权限分叉),owner 那条已在 1500。
  **一个闸门的两条腿不能差两档**,否则同一个时刻会因为你是谁而长得不一样。

`Dialog` 的消费者实测是 **17 个**(不是 14 —— 三个用多行 import,单行 grep 漏了):
13 个吃原语的遮罩、3 个自己往上escalate(1700/1700/1800,未动)、4 个用
`includeChromeClassName={false}` 完全不吃。**没有任何一个是故意压低的。**

## 守护测试

新增 `tests/styles/app-scrim-layer.test.ts`,按目录与 `components/chat/` 那条分工
(各自断言扫描到零个对方的文件,谁将来放宽范围都会红)。

判据四条同时满足:`position: fixed` + 覆盖全视口 + **画了背景** + **不是
`pointer-events: none`**。第三条把「点击捕获器」排除掉(全屏但透明的层职责就是待在
东西**下面**);第四条是本条新增 —— 我的范围够得着 `app-wash.css` 的 `body::before`,
它满足前三条却是 z −1 的装饰,没有它守护测试首轮会要求把整个应用背景抬到 150 以上。

尺子**只读 CSS 文本里的字面整数** —— jsdom 不做 `var()` 替换也不实现层叠,
`getComputedStyle` 在这里会空转假绿。夹具先证明它读得回 60/80/100 这些**改前的真值**。

改前 13 条红(6 个缺陷 × 地板 + 分档,加同闸门两腿同档),**其余 30 个遮罩全绿** ——
尺子会区分,不是见谁红谁。逐个还原逐个验红,每次只红它自己那几条。

## 未动,已记录

`.connectors-panel-embedded .connector-drawer-backdrop`(z 6,`position: absolute`)是
**故意压低**的,注释写着「so it does not stack above the settings modal」。它是**另一个
选择器**,抬基线够不着它;已在守护测试里钉成有意排除。

`.project-search-backdrop`(400)在地板之上,按判据不算缺陷,但它是 920 档以下唯一
剩下的遮罩,记在这里。
This commit is contained in:
lefarcen
2026-09-07 16:47:10 +08:00
parent 611ab085f7
commit 8d0b542d0a
7 changed files with 597 additions and 9 deletions
+14 -4
View File
@@ -591,9 +591,14 @@
The lift must stay BELOW `.entry-main__topbar` (z-index 10): the sticky
topbar is opaque chrome and the card scrolling under it must never cover
the chip cluster. It must also stay below the fixed modal family
(`.home-hero-confirm__backdrop` 80, `.plus-menu__popup` 90,
`.plugins-import-modal__backdrop` 100, `.use-everywhere-modal-backdrop`
920). Popover z-indexes inside the card (1503/1701/…) are local to the
(`.plus-menu__popup` 90, `.use-everywhere-modal-backdrop` 920, and the
app-modal tier at 1000 — `.home-hero-confirm__backdrop`,
`.plugins-import-modal__backdrop`, `.plugin-marketplace__modal-backdrop`,
`.automation-modal-backdrop`, plus the five `.backdrop` CSS modules that
define that tier). Those scrims used to sit at 60–100, i.e. UNDER the
shell chrome itself (`.workspace-tabs-chrome` 120, the top-right cluster
measured at 150) — see `tests/styles/app-scrim-layer.test.ts`.
Popover z-indexes inside the card (1503/1701/…) are local to the
card's stacking context and only order its own children. */
.home-hero__input-card:has(.inline-switcher__popover),
.home-hero__input-card:has(.home-hero__plugin-picker) {
@@ -2904,7 +2909,12 @@
.home-hero-confirm__backdrop {
position: fixed;
inset: 0;
z-index: 80;
/* 应用模态档(1000,和既有的五张 `.backdrop` 同档)。80 压不住 shell chrome
—— `.workspace-tabs-chrome` 是 120、右上角那簇真机量到 150,遮罩上会漏出
顶栏和右上角照常亮着。这张确认框走 `<Dialog includeChromeClassName={false}>`,
所以共享原语的遮罩样式不参与,只有这一条规则说了算。
守护:`tests/styles/app-scrim-layer.test.ts`。 */
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
@@ -663,7 +663,14 @@
.plugin-marketplace__modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
/* 应用模态档。60 压不住 shell chrome —— `.workspace-tabs-chrome` 是 120,
右上角那簇真机量到 150,于是遮罩上漏着顶栏照常亮着。1000 是应用里既有的
那一档(`BrandPickerModal` / `FigmaImportModal` / `LibraryPreviewModal` /
`LibraryUploadModal` / `NewBrandModal` 五张 `.backdrop` 都在这个数上),
不是这次挑的:它的含义是「占住整屏的模态,同屏对手只有 chrome 和页面内
的局部抬升,不需要压过另一张模态」。
守护:`tests/styles/app-scrim-layer.test.ts`。 */
z-index: 1000;
display: grid;
place-items: center;
padding: 28px;
+4 -1
View File
@@ -720,7 +720,10 @@
.plugins-import-modal__backdrop {
position: fixed;
inset: 0;
z-index: 100;
/* 应用模态档(1000,和既有的五张 `.backdrop` 同档)。100 压不住 shell chrome
—— `.workspace-tabs-chrome` 是 120、右上角那簇真机量到 150。
守护:`tests/styles/app-scrim-layer.test.ts`。 */
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
+4 -1
View File
@@ -811,7 +811,10 @@
.automation-modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
/* 应用模态档(1000,和既有的五张 `.backdrop` 同档)。80 压不住 shell chrome
—— `.workspace-tabs-chrome` 是 120、右上角那簇真机量到 150。
守护:`tests/styles/app-scrim-layer.test.ts`。 */
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
+8 -1
View File
@@ -2,7 +2,14 @@
.connector-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 100;
/* 应用模态档(1000,和既有的五张 `.backdrop` 同档)。100 压不住 shell chrome
—— `.workspace-tabs-chrome` 是 120、右上角那簇真机量到 150。
注意:这条是抽屉的**独立形态**。今天 `ConnectorsBrowser` 一律渲染在
`.connectors-panel-embedded` 里,生效的是文件下方那条面板内变体
(position: absolute、z-index: 6,故意压在设置弹窗之下),所以这个改动今天
在产品里看不出变化 —— 它修的是「抽屉独立挂载」那条契约的档位。
守护:`tests/styles/app-scrim-layer.test.ts`。 */
z-index: 1000;
background: color-mix(in srgb, #111827 18%, transparent);
-webkit-backdrop-filter: var(--scrim-backdrop);
backdrop-filter: var(--scrim-backdrop);
@@ -0,0 +1,533 @@
/**
* 应用层**全屏遮罩**的层级 —— `components/chat/` 之外的那一半。
*
* ## 和 `components/chat/chat-overlay-layer.test.ts` 怎么分的
*
* 按**目录**切,一个文件只属于一条守护,不重叠也不留缝:
*
* · `apps/web/src/components/chat/**` —— 归 `chat-overlay-layer.test.ts`
* · 其余 `apps/web/src/**` + `packages/components/src/**` —— 归这里
*
* 这条守护会主动断言「扫到的文件里没有一个在 `components/chat/` 下」,所以哪天
* 有人把范围放宽、两边开始互相断言同一个文件,这里立刻红。
*
* 判据是**同一把尺子**(见下面 §「什么算全屏遮罩」),但**断言不同**,这点必须写清楚:
*
* · chat 那边只有两张同族弹窗,所以它可以要求「这一族只许有一个档」;
* · 这边是全应用 30+ 张遮罩,档位**本来就该分层** —— 嵌套确认框要压过它被
* 从哪张模态里打开的那一张,`UpdateDialog` 要压过 `MessageCenter`,
* `FigmaHelpModal`(1600)是从 `FigmaImportModal`(1000)里点开的。
* 照抄「只许一个档」会把这些**正确**的分层判成错。
*
* 所以这边守的是**地板**(所有遮罩都必须压过同屏 chrome),外加**本批 6 处的
* 档位表**(我这次拍的板不许悄悄漂)。地板之上谁压谁,是各自的分层决定。
*
* ## 真机量到的层号
*
* 右上角 star / 额度胶囊 `.entry-top-right-cluster` **z = 150**
*
* 这 150 在样式表里**查不到**:`.entry-top-right-cluster` 自己没写 `z-index`,
* 它 portal 进 `.workspace-tabs-chrome`(z=120)的 `.app-chrome-actions` 里,
* 150 是那个层叠上下文的实测值。它是**二手数值**(来自 chat 那条守护的头注,
* 由 `elementFromPoint` 逐点探出),这里不复述出处,只标明:改它要重新量,
* 别去 grep。
*
* 另外两个参照层是**能 grep 的**,所以这里去文件里读,不写死:
* `.workspace-tabs-chrome.app-chrome-header` (`styles/shell.css`)
* `.chat-composer-fixed-layer` (`styles/chat.css`)
* 读而不写死的理由:写死的常数会在别人调整 chrome 时静静地留在旧值上,
* 于是守护看起来还在跑,实际上量的是一个已经不存在的地板。
*
* ## 什么算「全屏遮罩」(判据本身)
*
* 四条**同时**满足才收进来:
*
* 1. `position: fixed` —— 脱离文档流,钉在视口上
* 2. 铺满视口 —— `inset: 0`,或 top/right/bottom/left 四条都是 0
* 3. 画了一层底 —— 有 `background` / `background-color`,且不是
* `none` / `transparent`
* 4. 自己接得住点击 —— 不是 `pointer-events: none`
*
* 第 3 条和 chat 那条一致:铺满但透明的是「点外面关掉」的接盘层,它的职责就是
* 待在别的东西**下面**。
*
* ## 共享原语身上还压着一条同名的全局类
*
* `Dialog` 给遮罩同时挂了两个类:CSS Module 的 `styles.backdrop`,和字面量
* `'modal-backdrop'`。而 `styles/workspace/mention-home.css` 里有一条全局
* `.modal-backdrop { z-index: 1700 }`。两者都是 (0,1,0),谁赢由**文档顺序**决定,
* 不是由数字大小决定 —— 所以「原语写了 100」和「实际算出来是几」不是同一件事。
*
* 本地 `.next` 产物里量过:首屏 `<head>` 只挂 4 个全局 chunk,而
* `.dialog_backdrop{…z-index:100…}` 落在一个**按需加载**的 chunk 里(那个 chunk
* 里一条 `modal-backdrop` 都没有),后到者排在后面 → 原语那条赢。旁证是三个消费者
* (`AmrArtifactUpgradeDialog` 1700 / `DeepSeekV4FlashCampaign` 1700 /
* `GoPlanSunsetDialog` 1800)各自在自己的 `backdropClassName` 上写了 z-index:
* 要是全局那条 1700 本来就赢,这三处全是废笔。
*
* 这是**推断**,不是 `getComputedStyle` 量出来的 —— 打包器的注入顺序真要确认,
* 只有真机能确认。所以下面留了一条断言:不管谁赢,**两条都必须过地板**。
*
* 第 4 条是这边多出来的,因为这边扫得到 `styles/app-wash.css` 的 `body::before`
* —— 它 `position: fixed; inset: 0` 且画了 `var(--app-wash)`,前三条全中,但它
* 是**装饰背景**,`z-index: -1` 且 `pointer-events: none`。遮罩许的诺是「我后面
* 的东西被压暗了、**点不动了**」;许了这个诺就必须自己接住点击。装饰层不接,
* 所以它本来就不在这一族里。少了第 4 条,这条守护第一次跑就会去要求把整张
* 应用底纹抬到 150 以上 —— 那是错的。
*/
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(here, '../../../..');
const WEB_SRC = resolve(REPO, 'apps/web/src');
const COMPONENTS_SRC = resolve(REPO, 'packages/components/src');
/** chat 那条守护的地盘;这里必须一个文件都不碰 */
const CHAT_DIR = resolve(WEB_SRC, 'components/chat');
/**
* 真机 `elementFromPoint` 探到的右上角那簇的层号。样式表里查不到,见头注。
* 二手数值:它来自 `components/chat/chat-overlay-layer.test.ts` 的头注,
* 不是这条守护自己量的。
*/
const TOP_RIGHT_CHROME_Z = 150;
// ---------------------------------------------------------------- CSS 读取
interface StyleRule {
/** 相对仓库根的路径,失败信息里直接能定位 */
file: string;
selector: string;
/** 声明块;嵌套子块已被抠掉,免得子块的声明被当成本块的 */
decls: string;
}
const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, '');
/**
* 花括号配对的规则切分。必须配对而不是 `split('}')` —— 这个范围里
* `tasks.css` / `drawer.css` / `home-hero.css` 全都有 `@media`,按 `}` 硬切
* 会把条件组的头当成选择器,断言从此空转。
*/
function parseRules(css: string, file: string, out: StyleRule[]): void {
let i = 0;
while (i < css.length) {
const open = css.indexOf('{', i);
if (open < 0) break;
const prelude = css.slice(i, open).trim();
let depth = 1;
let j = open + 1;
while (j < css.length && depth > 0) {
if (css[j] === '{') depth += 1;
else if (css[j] === '}') depth -= 1;
j += 1;
}
const body = css.slice(open + 1, j - 1);
if (prelude.startsWith('@')) {
// 条件组:里面还是普通规则,钻进去。`@keyframes` / `@font-face` 不是。
if (/^@(?:media|supports|container|layer|scope)\b/.test(prelude)) {
parseRules(body, file, out);
}
} else if (prelude !== '') {
out.push({
file,
selector: prelude.replace(/\s+/g, ' '),
decls: body.replace(/\{[^{}]*\}/g, ' '),
});
}
i = j;
}
}
function cssFilesUnder(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) found.push(...cssFilesUnder(full));
else if (entry.name.endsWith('.css')) found.push(full);
}
return found.sort();
}
/** 本条守护的扫描范围:web 的 src(去掉 chat)+ 共享原语包 */
function inScopeCssFiles(): string[] {
return [...cssFilesUnder(WEB_SRC), ...cssFilesUnder(COMPONENTS_SRC)].filter(
(file) => !file.startsWith(`${CHAT_DIR}/`),
);
}
const RULES: StyleRule[] = (() => {
const rules: StyleRule[] = [];
for (const file of inScopeCssFiles()) {
parseRules(stripComments(readFileSync(file, 'utf8')), relative(REPO, file), rules);
}
return rules;
})();
// ---------------------------------------------------------------- 判据
/** 取一条声明的值;取不到返回 `null` */
function decl(decls: string, prop: string): string | null {
const re = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i');
const m = re.exec(decls);
return m?.[1] !== undefined ? m[1].trim() : null;
}
/** 铺满视口:`inset: 0`,或者四条边各自归零的老写法 */
function coversViewport(decls: string): boolean {
const inset = decl(decls, 'inset');
if (inset !== null && /^0(?:px|%)?$/.test(inset)) return true;
return (['top', 'right', 'bottom', 'left'] as const).every((side) => {
const v = decl(decls, side);
return v !== null && /^0(?:px|%)?$/.test(v);
});
}
/** 画了一层看得见的底(不是接盘用的透明层) */
function paintsScrim(decls: string): boolean {
const bg = decl(decls, 'background') ?? decl(decls, 'background-color');
if (bg === null) return false;
return !/^(?:none|transparent|initial|unset)$/i.test(bg);
}
/** 自己接得住点击 —— 装饰层不接,见头注第 4 条 */
function catchesPointer(decls: string): boolean {
return decl(decls, 'pointer-events') !== 'none';
}
/** 头注 §「什么算全屏遮罩」的四条,合起来就是这个函数 */
function isFullscreenScrim(rule: StyleRule): boolean {
return (
decl(rule.decls, 'position') === 'fixed' &&
coversViewport(rule.decls) &&
paintsScrim(rule.decls) &&
catchesPointer(rule.decls)
);
}
const SCRIMS = RULES.filter(isFullscreenScrim);
/**
* z-index 的字面数值。
*
* 读的是 **CSS 文本里的整数**,不是 `getComputedStyle` —— jsdom 不做 `var()`
* 替换、也不跑层叠,拿它量出来的 z 是个假绿判据(拿到 `''` 也一样「通过」)。
* 不是字面数字(比如走了 token)就返回 `null`,由下面第一条断言接住。
*/
function zIndexOf(decls: string): number | null {
const v = decl(decls, 'z-index');
return v !== null && /^-?\d+$/.test(v) ? Number(v) : null;
}
// ------------------------------------------------ 能 grep 的两个参照层
function ruleIn(relPath: string, selector: string): StyleRule | undefined {
const rules: StyleRule[] = [];
parseRules(stripComments(readFileSync(resolve(REPO, relPath), 'utf8')), relPath, rules);
return rules.find((r) => r.selector.split(',').some((s) => s.trim() === selector));
}
function referenceZ(relPath: string, selector: string): number {
const rule = ruleIn(relPath, selector);
expect(rule, `${relPath} 里找不到 ${selector} —— 选择器改名了,地板会算错`).toBeTruthy();
const z = zIndexOf(rule!.decls);
expect(z, `${relPath} ${selector} 没有字面 z-index`).not.toBeNull();
return z!;
}
const TABS_CHROME_Z = referenceZ('apps/web/src/styles/shell.css', '.workspace-tabs-chrome.app-chrome-header');
const COMPOSER_LAYER_Z = referenceZ('apps/web/src/styles/chat.css', '.chat-composer-fixed-layer');
/** 同屏 chrome 的地板:三者取最高 */
const CHROME_FLOOR = Math.max(TOP_RIGHT_CHROME_Z, TABS_CHROME_Z, COMPOSER_LAYER_Z);
// ================================================================ 量法自检
//
// 先证明这把尺子在**修复之前**就能把错的读成错的。少了这一组,下面全绿也
// 可能只是尺子什么都没量到。
describe('先证明这把尺子能照出缺陷', () => {
/**
* 一份合成语料,覆盖本范围真实存在的每一种形态 —— 本批那 6 处的**修复前**
* 原值(60 / 80 / 100)各一条,外加四种必须被排除掉的。
*/
const FIXTURE = `
.marketplaceBackdrop { position: fixed; inset: 0; z-index: 60;
background: color-mix(in srgb, #000 28%, transparent); }
.confirmBackdrop { position: fixed; inset: 0; z-index: 80; background: var(--scrim-tint); }
.sharedPrimitive { position: fixed; inset: 0; z-index: 100;
background: color-mix(in srgb, #202020 42%, transparent); }
.oldSchool { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 7;
background: rgba(0, 0, 0, 0.3); }
.appWash { position: fixed; inset: 0; z-index: -1; pointer-events: none;
background: var(--app-wash); }
.clickCatcher { position: fixed; inset: 0; z-index: 40; background: transparent; }
.inPanelDrawer { position: absolute; inset: 0; z-index: 6;
background: color-mix(in srgb, var(--bg) 24%, transparent); }
.anchoredMenu { position: absolute; bottom: calc(100% + 7px); z-index: 140; background: var(--bg-panel); }
@media (max-width: 600px) { .scrimInMedia { position: fixed; inset: 0; z-index: 3; background: #000; } }
`;
const fixtureRules: StyleRule[] = [];
parseRules(stripComments(FIXTURE), 'fixture.css', fixtureRules);
it('规则切分认得 @media —— 认不出来的话条件组里的遮罩会被漏掉', () => {
expect(fixtureRules.map((r) => r.selector)).toEqual([
'.marketplaceBackdrop',
'.confirmBackdrop',
'.sharedPrimitive',
'.oldSchool',
'.appWash',
'.clickCatcher',
'.inPanelDrawer',
'.anchoredMenu',
'.scrimInMedia',
]);
});
it('判据只挑出全屏遮罩 —— 装饰底纹 / 接盘层 / 面板内抽屉 / 锚定菜单都不在内', () => {
expect(fixtureRules.filter(isFullscreenScrim).map((r) => r.selector)).toEqual([
'.marketplaceBackdrop',
'.confirmBackdrop',
'.sharedPrimitive',
'.oldSchool',
'.scrimInMedia',
]);
});
it('装饰底纹被排除的理由是「不接点击」,不是别的三条 —— 反向确认', () => {
const wash = fixtureRules.find((r) => r.selector === '.appWash')!;
expect(decl(wash.decls, 'position')).toBe('fixed');
expect(coversViewport(wash.decls)).toBe(true);
expect(paintsScrim(wash.decls)).toBe(true);
// 前三条全中,只栽在第四条
expect(catchesPointer(wash.decls)).toBe(false);
});
it('读得出那三个错的数 —— 60 / 80 / 100,正是本批修复前的现场值', () => {
const read = (sel: string) =>
zIndexOf(fixtureRules.find((r) => r.selector === sel)!.decls);
expect(read('.marketplaceBackdrop')).toBe(60);
expect(read('.confirmBackdrop')).toBe(80);
expect(read('.sharedPrimitive')).toBe(100);
// 而且这三个读数确实过不了地板:尺子有刻度,不是恒真
for (const z of [60, 80, 100]) expect(z).toBeLessThan(CHROME_FLOOR);
});
it('两个能 grep 的参照层都拿得到,而且地板确实由那 150 定 —— 拿不到就说明选择器改名了', () => {
expect(TABS_CHROME_Z).toBeGreaterThan(0);
expect(COMPOSER_LAYER_Z).toBeGreaterThan(0);
expect(CHROME_FLOOR).toBe(TOP_RIGHT_CHROME_Z);
});
it('扫到了东西,而且一个 chat 的文件都没碰', () => {
// 空集合会让下面的 for 循环一条断言都不跑 —— 那是最坏的一种「全绿」
expect(SCRIMS.length).toBeGreaterThan(20);
const trespass = SCRIMS.filter((s) => s.file.includes('apps/web/src/components/chat/'));
expect(
trespass.map((s) => `${s.file} ${s.selector}`),
'chat 目录归 chat-overlay-layer.test.ts 守;两边都断言同一个文件,' +
'改一个数字会在两处红,下一个人只会把其中一条删掉',
).toEqual([]);
// 反向:chat 目录里确实有遮罩(不是因为那边没东西才「没重叠」)
expect(cssFilesUnder(CHAT_DIR).length).toBeGreaterThan(0);
});
});
// ================================================================ 守护本体:地板
describe('应用层全屏遮罩的地板', () => {
for (const scrim of SCRIMS) {
const where = `${scrim.file} ${scrim.selector}`;
describe(where, () => {
it('写了字面 z-index —— 遮罩必须自报档位', () => {
expect(
zIndexOf(scrim.decls),
`${where} 没有字面 z-index。改走 token 是可以的,但这条守护得先学会解析它,` +
'否则它会静静地不再守任何东西',
).not.toBeNull();
});
it(`压得住同屏 chrome(地板 ${CHROME_FLOOR})`, () => {
expect(
zIndexOf(scrim.decls)!,
`${where} 排在同屏 chrome 之下 —— 遮罩上会漏出那一块照常亮着。` +
`地板 = max(右上角那簇 ${TOP_RIGHT_CHROME_Z}(真机量的), ` +
`.workspace-tabs-chrome ${TABS_CHROME_Z}, ` +
`.chat-composer-fixed-layer ${COMPOSER_LAYER_Z})`,
).toBeGreaterThan(CHROME_FLOOR);
});
});
}
});
// ================================================================ 守护本体:本批的档位
/**
* 本批 6 处各自抬到哪一档,以及**为什么是那一档**。
*
* 地板之上谁压谁是分层决定,不是自由值 —— 所以这张表把这次拍的板钉住:
* 改任何一个数字都要先改这张表,顺带写下新的理由。
*/
describe('本批 6 处的档位', () => {
/**
* 「应用模态」档 = 1000。
*
* 这个数**不是我挑的** —— 应用里已经有五张 `.backdrop` 稳稳落在 1000,
* 它们就是这一档的定义:一张占住整屏的模态,同屏对手只有 shell chrome
* (`.workspace-tabs-chrome` 120 / 右上角那簇 150)和页面内的局部抬升
* (`.project-search-backdrop` 400、home hero 卡片 ≤ 40),不需要压过另一张模态。
*/
const APP_MODAL_TIER = 1000;
const APP_MODAL_ANCHORS = [
'apps/web/src/components/BrandPickerModal.module.css',
'apps/web/src/components/FigmaImportModal.module.css',
'apps/web/src/components/LibraryPreviewModal.module.css',
'apps/web/src/components/LibraryUploadModal.module.css',
'apps/web/src/components/NewBrandModal.module.css',
];
it(`「应用模态」档确实是 ${APP_MODAL_TIER} —— 由既有的五张模态定义,不是这次挑的数`, () => {
for (const file of APP_MODAL_ANCHORS) {
const rule = ruleIn(file, '.backdrop');
expect(rule, `${file} 里找不到 .backdrop —— 锚点没了,这一档就成了空口数字`).toBeTruthy();
expect(zIndexOf(rule!.decls), `${file} .backdrop`).toBe(APP_MODAL_TIER);
}
});
const RULED: { file: string; selector: string; z: number; why: string }[] = [
{
file: 'apps/web/src/styles/home/plugin-marketplace-demo.css',
selector: '.plugin-marketplace__modal-backdrop',
z: APP_MODAL_TIER,
why: '插件市场「新建插件」面板。整屏模态,同屏只有 shell chrome —— 应用模态档',
},
{
file: 'apps/web/src/styles/home/home-hero.css',
selector: '.home-hero-confirm__backdrop',
z: APP_MODAL_TIER,
why:
'home hero 的「替换附件?」确认。它走 <Dialog includeChromeClassName={false}>,' +
'所以只有这一条规则说了算;同屏对手是 hero 卡片的局部抬升(9 / 40)—— 应用模态档',
},
{
file: 'apps/web/src/styles/home/tasks.css',
selector: '.automation-modal-backdrop',
z: APP_MODAL_TIER,
why: '自动化「新建」模态。Automations 页的整屏模态 —— 应用模态档',
},
{
file: 'apps/web/src/styles/home/plugins-view.css',
selector: '.plugins-import-modal__backdrop',
z: APP_MODAL_TIER,
why: '插件导入模态。Plugins 页的整屏模态 —— 应用模态档',
},
{
file: 'apps/web/src/styles/workspace/drawer.css',
selector: '.connector-drawer-backdrop',
z: APP_MODAL_TIER,
why:
'连接器详情抽屉的**独立形态**。今天 ConnectorsBrowser 一律渲染在 ' +
'`.connectors-panel-embedded` 里,所以生效的是下面那条面板内变体;' +
'这条 fixed 规则是「抽屉独立挂载」的契约,给它一个正确的档 —— 应用模态档',
},
{
file: 'packages/components/src/dialog.module.css',
selector: '.backdrop',
z: 1500,
why:
'共享 Dialog 原语。**不在**应用模态档,因为它没有自己的屏:它既从 EntryShell ' +
'渲染,也从 ProjectView(聊天输入框那一屏)渲染,还会**嵌在别的模态里**' +
'(NewProjectPanel 的删除确认长在 `.new-project-modal-backdrop` 920 之内,' +
'在原来的 100 上它压在自己父模态的遮罩底下)。一个通用原语的地板必须覆盖这些的并集',
},
];
for (const { file, selector, z, why } of RULED) {
it(`${file} ${selector} → ${z} —— ${why}`, () => {
const rule = ruleIn(file, selector);
expect(rule, `${file} 里找不到 ${selector} —— 改名或删了,这条档位说明已经过期`).toBeTruthy();
expect(isFullscreenScrim(rule!), `${file} ${selector} 不再满足全屏遮罩判据`).toBe(true);
expect(zIndexOf(rule!.decls)).toBe(z);
});
}
/**
* 共享原语为什么正好是 1500,而不是「比 1000 高一点」的随手数字。
*
* `AmrBalanceDialog`(有账单权限)和 `AmrOwnerTopUpDialog`(没有账单权限)是
* **同一个余额闸门的两个分支** —— 在 `EntryShell.tsx` 和 `ProjectView.tsx` 里
* 它们是同一个三元表达式的两条腿,同一处渲染,只按权限分叉。后者已经落在
* 1500;前者不带 `backdropClassName`,吃的就是这个原语的遮罩。
* 同一个闸门的两条腿不能差两档 —— 那样同一件事会因为「你是谁」而长得不一样。
*
* 这是一次**单向读取**:这条守护读 chat 那边的数,chat 那条不读这边。
* 哪天 chat 那边挪了 1500,这里红 —— 那正是要的:另一条腿得跟着挪。
*/
/**
* `Dialog` 的遮罩身上同时挂着 CSS Module 的 `.backdrop` 和字面量
* `.modal-backdrop`(见头注)。两条同特异度,谁赢看打包器的注入顺序 ——
* 那不是这条守护量得出来的。所以这里守的不是「谁赢」,而是**赢家一定过地板**:
* 只要两条都在地板之上,顺序怎么翻都不会漏出 chrome。
*/
it('原语和它身上那条全局 .modal-backdrop 都过地板 —— 谁赢都不漏', () => {
const primitive = ruleIn('packages/components/src/dialog.module.css', '.backdrop');
const globalTwin = ruleIn('apps/web/src/styles/workspace/mention-home.css', '.modal-backdrop');
expect(globalTwin, 'mention-home.css 的 .modal-backdrop 没了 —— 这条共存说明已经过期').toBeTruthy();
expect(zIndexOf(primitive!.decls)!).toBeGreaterThan(CHROME_FLOOR);
expect(zIndexOf(globalTwin!.decls)!).toBeGreaterThan(CHROME_FLOOR);
// 反向:`Dialog` 确实还在挂那个字面量类,不然这条共存是空谈
const dialogTsx = readFileSync(resolve(REPO, 'packages/components/src/dialog.tsx'), 'utf8');
expect(dialogTsx).toContain("'modal-backdrop'");
});
it('共享原语和余额闸门的另一条腿同档 —— 同一个闸门不许因为「你是谁」而分层', () => {
const sibling = ruleIn(
'apps/web/src/components/chat/AmrOwnerTopUpDialog.module.css',
'.overlay',
);
expect(sibling, 'AmrOwnerTopUpDialog 的 .overlay 没了 —— 这条档位理由已经过期').toBeTruthy();
const primitive = ruleIn('packages/components/src/dialog.module.css', '.backdrop');
expect(zIndexOf(primitive!.decls)).toBe(zIndexOf(sibling!.decls));
});
});
// ================================================================ 排除项
/**
* 判据取窄的代价是「有东西被排除」,那就把排除**钉住**:哪天有人把判据放宽,
* 这些各有各层位契约的层被拖进来,这里立刻红。
*/
describe('排除项是有意的', () => {
const excluded = [
{
file: 'apps/web/src/styles/app-wash.css',
selector: 'body::before',
why:
'应用底纹:position: fixed + inset: 0 + 画了底,前三条全中,但它 ' +
'pointer-events: none、z-index: -1 —— 是装饰背景,不是遮罩。' +
'它没许「后面点不动了」那个诺,所以不归这一族管',
},
{
file: 'apps/web/src/styles/workspace/drawer.css',
selector: '.connectors-panel-embedded .connector-drawer-backdrop',
why:
'**故意压低**的那一个:面板内形态,position: absolute、z-index: 6,' +
'按注释是「so it does not stack above the settings modal」。' +
'它不是 fixed,所以抬上面那条独立形态碰不到它 —— 但这条排除要钉住,' +
'免得哪天有人「顺手统一」把它也抬上去,连接器抽屉会盖住设置弹窗自己',
},
];
for (const { file, selector, why } of excluded) {
it(`${file} ${selector} 不在守护范围内 —— ${why}`, () => {
const rule = ruleIn(file, selector);
expect(rule, `${file} 里找不到 ${selector} —— 改名或删了,这条排除说明已经过期`).toBeTruthy();
expect(isFullscreenScrim(rule!)).toBe(false);
});
}
});
+26 -1
View File
@@ -8,7 +8,32 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
/*
* 模态档。原来是 100,压不住同屏的任何一层 —— shell 的
* `.workspace-tabs-chrome` 是 120,右上角那簇 `.entry-top-right-cluster`
* 真机 `elementFromPoint` 探到是 150。于是每一张吃这个原语的弹窗,遮罩上
* 都漏着顶栏和右上角照常亮着的那一块。
*
* 为什么是 1500,而不是「比应用模态档 1000 高一点」的随手数字:
*
* · 这个原语**没有自己的屏**。它既从 `EntryShell` 渲染,也从 `ProjectView`
* (聊天输入框那一屏)渲染,还会**嵌在别的模态里** —— `NewProjectPanel`
* 的删除确认长在 `.new-project-modal-backdrop`(920)之内,在原来的 100 上
* 它压在自己父模态的遮罩底下。通用原语的地板必须覆盖这些的并集,所以它
* 不属于那五张 `.backdrop` 定义的「应用模态档 1000」。
* · 1500 这个数取自**余额闸门的另一条腿**:`AmrBalanceDialog`(有账单权限)
* 和 `AmrOwnerTopUpDialog`(没有账单权限)是同一个闸门的两个分支,在
* `EntryShell.tsx` / `ProjectView.tsx` 里是同一个三元表达式的两条腿。后者
* 已经落在 1500(`components/chat/AmrOwnerTopUpDialog.module.css`),前者
* 不带 `backdropClassName`,吃的就是这里。同一个闸门的两条腿不能差两档。
*
* 需要压过另一张模态的消费者仍然在自己的 `backdropClassName` 上往上走
* (`AmrArtifactUpgradeDialog` 1700 / `DeepSeekV4FlashCampaign` 1700 /
* `GoPlanSunsetDialog` 1800),那条升级通道不受影响。
*
* 守护:`apps/web/tests/styles/app-scrim-layer.test.ts`。
*/
z-index: 1500;
animation: dialog-backdrop-fade-in var(--duration-fast, 150ms) var(--curve-decelerate-mid, ease-out);
}