/** * 自定义背景的结构性检查。 * * 与 theme.test.mjs 同一风格:判据是「源码里存在/不存在某种形态」,不需要浏览器。 * 真正的视觉验收靠手工脚本(test/manual/)。 * * 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是 * 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。 */ import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const read = p => readFileSync(join(here, p), 'utf8'); let pass = 0; let fail = 0; const check = (name, ok, detail = '') => { if (ok) { pass++; console.log(` 通过 ${name}`); } else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); } }; const css = read('../src/index.css').replace(/\/\*[\s\S]*?\*\//g, ''); const store = read('../src/stores/backgroundStore.ts'); const picker = read('../src/components/BackgroundPicker.tsx'); const main = read('../src/main.tsx'); const rootBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark')); const darkBlock = css.slice(css.indexOf('.dark {')); // 1) 遮罩颜色必须两种主题各一份。 // 只有一个值时,深色模式下用白色遮罩会把照片洗成一块亮斑,正文完全读不动。 check( '--bg-scrim 在浅色与深色下都有定义', /--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(rootBlock) && /--bg-scrim:\s*\d+\s+\d+\s+\d+/.test(darkBlock), '深色缺少遮罩色时浅色照片会压不住' ); // 2) 遮罩色必须是 RGB 三元组(与色板同一约定),否则 rgb(var() / var()) 无效。 check( '--bg-scrim 存 RGB 三元组', /--bg-scrim:\s*\d+\s+\d+\s+\d+;/.test(rootBlock) && !/--bg-scrim:\s*#/.test(css) ); // 3) 背景层必须画在内容**之下**。 // z-index:0 / auto 的定位元素会画在常规流内容之上,把整个界面盖住。 check( '背景层用负 z-index 且不吃点击', /\.app-backdrop\s*\{[^}]*z-index:\s*-1/.test(css) && /\.app-backdrop\s*\{[^}]*pointer-events:\s*none/.test(css), 'z-index 不为负会盖住界面' ); // 4) 背景开启时必须让出不透明的页面底,否则背景永远看不见。 // 这是最容易漏的一条:写好了渐变、挂好了层,却被 bg-gray-50 挡住。 // // 正则要写成「选择器列表 … { 声明体 }」而不是 [^{]* 直接跨到声明: // [^{] 遇 { 即停,而这里要跨过的正是选择器后面的那个 {。 check( 'data-bg=on 时页面底变透明', /html\[data-bg='on'\]\s+body/.test(css) && /html\[data-bg='on'\][^{}]*\.bg-gray-50/.test(css) && /html\[data-bg='on'\][^{}]*\{[^}]*background-color:\s*transparent/.test(css) ); // 5) 玻璃化只应在背景开启时生效。 // 若无条件给 .bg-white 加半透明/模糊,关闭背景的用户会看到一层发灰的卡片。 const glassRules = css.match(/html\[data-bg='on'\][^{]*\{[^}]*backdrop-filter/g) || []; const bareGlass = /(^|\n)\s*\.bg-white\s*\{[^}]*backdrop-filter/.test(css); check( 'backdrop-filter 仅在 data-bg=on 下使用', glassRules.length > 0 && !bareGlass, bareGlass ? '存在无条件的 .bg-white 模糊规则' : '未找到玻璃化规则' ); // 6) 悬停态也要接管。 // 不接管的话鼠标一进面板就从不透明闪回,观感是明显的跳动。 check( '悬停态一并在背景模式下接管', /html\[data-bg='on'\][^{]*\.hover\\:bg-gray-50:hover/.test(css) ); // 7) 预设渐变只能由 CSS 提供色值,组件里不得写死颜色。 // 写死十六进制会绕过主题变量 —— 深色模式下会原样落下浅色渐变。 const presetClasses = (css.match(/\.bg-preset-[a-z]+\s*\{/g) || []).length; const hexInPicker = picker.match(/#[0-9a-fA-F]{3,6}\b/g) || []; check( '预设渐变定义在 CSS 且组件无写死颜色', presetClasses >= 4 && hexInPicker.length === 0, hexInPicker.length ? `组件含 ${hexInPicker.join(',')}` : `只找到 ${presetClasses} 个预设` ); // 8) 预设必须复用调色板变量(因此自动随主题变),而不是字面色值。 check( '预设渐变复用调色板变量', /\.bg-preset-aurora\s*\{[^}]*rgb\(var\(--c-/.test(css) ); // 9) 背景必须在 render 之前套用,且在 App 之外挂载。 // 挂在 App 内会只存在于主界面分支上,登录页/加载页没有背景。 check( '启动时套用背景且挂在 App 之外', /initBackground\(\)/.test(main) && /className="app-backdrop"/.test(main), '缺少 initBackground 或背景层不在 App 外' ); // 10) 存储键与归一化入口存在(旧数据/脏数据不能让页面白屏)。 check( '有独立存储键与脏数据归一化', /STORAGE_KEY\s*=\s*'agentmail\.background'/.test(store) && /normalizeBackground/.test(store) ); // 11) 图片必须压缩后再存,且有明确上限。 // 手机直出照片 4–8MB,直接塞 localStorage 会超配额并抛异常 —— // 用户看到的是「选了图片没反应」。 check( '图片有缩放与体积上限', /MAX_EDGE\s*=\s*\d+/.test(store) && /MAX_DATA_URL_BYTES\s*=\s*[\d_]+/.test(store) && /drawScaled/.test(store) ); // 12) 失败必须给出原因,不能静默。 check( '图片处理失败返回原因', /ok:\s*false;\s*reason:\s*string/.test(store) && /role="alert"/.test(picker) ); // 13) 背景是装饰偏好,写 DOM 失败不得抛出打断操作。 check( '背景写入 DOM 前有环境判断', /typeof document === 'undefined'/.test(css.slice(0, 0) + store) ); // 14) 动效必须尊重 prefers-reduced-motion。 check( '尊重 prefers-reduced-motion', /@media\s*\(prefers-reduced-motion:\s*reduce\)/.test(css) ); // 15) 组件里不得出现未映射色族(emerald/purple 等会绕过主题)。 const compDir = join(here, '../src/components'); const unmapped = []; for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) { const src = readFileSync(join(compDir, f), 'utf8'); if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f); } check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' ')); console.log(`\n背景:${pass} 通过${fail ? `,${fail} 失败` : ''}`); process.exit(fail ? 1 : 0);