Files
MyAiDesk/nomifun-tauri/scripts/check-theme-contract.mjs
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

289 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 预设 CSS 主题契约校验 / Preset CSS theme contract checker
* 校验 ui/src/renderer/pages/settings/DisplaySettings/presets/*.cssdefault.css 除外)
* 是否符合 presets/README.md 的主题契约:
* - 双块结构(:root,body 亮块 + [data-theme='dark'],[data-theme='dark'] body 暗块,暗块在后)
* - A 表 + B 表变量全量覆盖,且亮/暗两块变量集合对称
* - --primary-rgb / --primary-1..7 为 RGB 三元组
* - 无布局属性、无 @import / 外联 url、变量不进 @media、大括号配平
* - 内容弹层背景不过透明、消息排版外层不套主题背景
* - 预览缩略图取色键存在
*
* 用法 / Usage: node scripts/check-theme-contract.mjs (或 bun)
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const PRESETS_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'ui/src/renderer/pages/settings/DisplaySettings/presets'
);
const range = (prefix, from, to) => Array.from({ length: to - from + 1 }, (_, i) => `${prefix}${from + i}`);
/** 契约 A 表:App 变量 / Contract table A: app variables */
const APP_VARS = [
'color-primary',
'primary',
...range('color-primary-light-', 1, 3),
'color-primary-dark-1',
'primary-rgb',
'brand',
'brand-light',
'brand-hover',
'color-brand-fill',
'color-brand-bg',
...range('aou-', 1, 10),
'bg-base',
...range('bg-', 1, 6),
'bg-8',
'bg-9',
'bg-10',
'bg-hover',
'bg-active',
'fill',
'color-fill',
'fill-0',
'fill-white-to-black',
'dialog-fill-0',
'inverse',
'text-primary',
'text-secondary',
'text-disabled',
'text-0',
'text-white',
'border-base',
'border-light',
'border-special',
'success',
'warning',
'danger',
'info',
'message-user-bg',
'message-tips-bg',
'workspace-btn-bg',
'color-guid-agent-bar',
'terminal-surface-bg',
'terminal-border',
];
/** 契约 B 表:Arco token(必须打在含 body 的选择器组) / Contract table B: Arco tokens */
const ARCO_VARS = [
...range('color-bg-', 1, 5),
'color-bg-popup',
'color-bg-white',
...range('color-text-', 1, 4),
...range('color-fill-', 1, 4),
'color-border',
...range('color-border-', 1, 4),
'color-primary-light-4',
...range('primary-', 1, 7),
'color-secondary',
'color-secondary-hover',
'color-secondary-active',
'color-secondary-disabled',
'color-tooltip-bg',
'color-mask-bg',
'color-spin-layer-bg',
'color-menu-light-bg',
'color-menu-dark-bg',
];
const REQUIRED = [...APP_VARS, ...ARCO_VARS];
const TRIPLET_VARS = ['primary-rgb', ...range('primary-', 1, 7)];
const PREVIEW_KEYS = ['bg-1', 'bg-2', 'bg-3', 'color-primary', 'color-text-3', 'color-fill-2', 'color-primary-light-3'];
const LAYOUT_PROPS = new Set([
'display',
'position',
'overflow',
'overflow-x',
'overflow-y',
'z-index',
'width',
'height',
'min-width',
'max-width',
'min-height',
'max-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
]);
const MESSAGE_ITEM_FORBIDDEN_PROPS = new Set([
'background',
'background-color',
'background-image',
'backdrop-filter',
'-webkit-backdrop-filter',
]);
const CONTENT_POPOVER_SELECTORS = ['.arco-popover-content', '.arco-dropdown-menu', '.arco-select-popup'];
const MIN_CONTENT_SURFACE_ALPHA = 0.86;
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
/** 顶层块扫描(@media/@keyframes 整体视作一个块) / Top-level block scan */
const topLevelBlocks = (css) => {
const blocks = [];
let i = 0;
while (i < css.length) {
const open = css.indexOf('{', i);
if (open === -1) break;
const prevClose = css.lastIndexOf('}', open);
const selector = css
.slice(prevClose === -1 ? 0 : prevClose + 1, open)
.trim()
.replace(/\s+/g, ' ');
let depth = 1;
let j = open + 1;
while (j < css.length && depth > 0) {
if (css[j] === '{') depth++;
else if (css[j] === '}') depth--;
j++;
}
blocks.push({ selector, body: css.slice(open + 1, j - 1), start: open, end: j });
i = j;
}
return blocks;
};
const collectVars = (body) => {
const map = new Map();
const re = /--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);/g;
let m;
while ((m = re.exec(body)) !== null) map.set(m[1], m[2].trim());
return map;
};
const isTriplet = (value) => /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(value.replace(/\s*!important\s*/i, ''));
const declarationEntries = (body) => {
const entries = [];
const re = /(?:^|[{;])\s*([-\w]+)\s*:\s*([^;{}]+);/g;
let m;
while ((m = re.exec(body)) !== null) entries.push({ prop: m[1], value: m[2].trim() });
return entries;
};
const rgbaAlphaValues = (value) => {
const alphas = [];
const re = /rgba\(\s*[^,]+\s*,\s*[^,]+\s*,\s*[^,]+\s*,\s*([0-9.]+)\s*\)/gi;
let m;
while ((m = re.exec(value)) !== null) alphas.push(Number(m[1]));
return alphas.filter((n) => Number.isFinite(n));
};
const checkTheme = (file, css) => {
const problems = [];
const cleaned = stripComments(css);
// 大括号配平 / brace balance
const opens = (cleaned.match(/\{/g) || []).length;
const closes = (cleaned.match(/\}/g) || []).length;
if (opens !== closes) problems.push(`大括号不配平: { x${opens} vs } x${closes}`);
// 外联资源 / external resources
if (/@import\b/.test(cleaned)) problems.push('包含 @import');
if (/url\(\s*['"]?https?:/i.test(cleaned)) problems.push('包含外联 url(http...)');
const blocks = topLevelBlocks(cleaned);
const lightBlocks = blocks.filter((b) => /:root/.test(b.selector) && /(^|[,\s])body\b/.test(b.selector));
const darkBlocks = blocks.filter(
(b) => /\[data-theme=["']?dark["']?\]/.test(b.selector) && /\[data-theme=["']?dark["']?\]\s+body\b/.test(b.selector)
);
if (lightBlocks.length === 0) problems.push('缺少亮色块(选择器需同时含 :root 与 body');
if (darkBlocks.length === 0) problems.push("缺少暗色块选择器需含 [data-theme='dark'] [data-theme='dark'] body");
if (lightBlocks.length && darkBlocks.length && darkBlocks[0].start < lightBlocks[0].start) {
problems.push('暗色块出现在亮色块之前');
}
const lightVars = new Map();
for (const b of lightBlocks) for (const [k, v] of collectVars(b.body)) lightVars.set(k, v);
const darkVars = new Map();
for (const b of darkBlocks) for (const [k, v] of collectVars(b.body)) darkVars.set(k, v);
for (const v of REQUIRED) {
if (!lightVars.has(v)) problems.push(`亮色块缺变量 --${v}`);
if (!darkVars.has(v)) problems.push(`暗色块缺变量 --${v}`);
}
// 对称性(除契约清单外的自定义变量也要求对称,--sider-section-title-color 例外)
const symmetricExempt = new Set(['sider-section-title-color']);
for (const k of lightVars.keys()) {
if (!darkVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在亮色块出现(不对称)`);
}
for (const k of darkVars.keys()) {
if (!lightVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在暗色块出现(不对称)`);
}
for (const v of TRIPLET_VARS) {
for (const [mode, vars] of [
['亮', lightVars],
['暗', darkVars],
]) {
const value = vars.get(v);
if (value && !isTriplet(value)) problems.push(`${mode}色块 --${v} 不是 RGB 三元组: "${value}"`);
}
}
for (const key of PREVIEW_KEYS) {
if (!lightVars.has(key)) problems.push(`预览取色键 --${key} 在亮色块缺失`);
}
// 布局属性(@keyframes 块内豁免——其内是动画帧)/ layout props outside keyframes
for (const b of blocks) {
if (/@(?:-webkit-)?keyframes\b/.test(b.selector)) continue;
for (const { prop, value } of declarationEntries(b.body)) {
if (LAYOUT_PROPS.has(prop)) problems.push(`布局属性 "${prop}" 出现在选择器 "${b.selector.slice(0, 60)}"`);
if (b.selector.includes('.message-item') && MESSAGE_ITEM_FORBIDDEN_PROPS.has(prop)) {
problems.push(`禁止给 .message-item 设置 "${prop}"(消息排版外层不能套主题背景)`);
}
const isContentPopover = CONTENT_POPOVER_SELECTORS.some((selector) => b.selector.includes(selector));
if (isContentPopover && (prop === 'background' || prop === 'background-color')) {
const lowAlpha = rgbaAlphaValues(value).find((alpha) => alpha < MIN_CONTENT_SURFACE_ALPHA);
if (lowAlpha != null) {
problems.push(
`内容弹层 "${b.selector.slice(0, 60)}" ${prop} 透明度 ${lowAlpha} 过低 >= ${MIN_CONTENT_SURFACE_ALPHA}`
);
}
}
}
// 变量不进 @media / vars must not live inside @media
if (/^@media\b/.test(b.selector) && /--[a-zA-Z0-9-_]+\s*:/.test(b.body)) {
problems.push('@media 块内定义了 CSS 变量(预览解析不到)');
}
}
return problems;
};
const files = readdirSync(PRESETS_DIR).filter((f) => f.endsWith('.css') && f !== 'default.css');
let failed = false;
for (const file of files) {
const css = readFileSync(join(PRESETS_DIR, file), 'utf8');
const problems = checkTheme(file, css);
if (problems.length) {
failed = true;
console.log(` ${file}`);
for (const p of problems) console.log(` - ${p}`);
} else {
console.log(` ${file}`);
}
}
if (!files.length) {
console.log('presets 目录下没有非 default 主题)');
}
process.exit(failed ? 1 : 0);