返回 DeepSeek-Reasonix
check-theme-token-contract.mjs
根目录 / desktop / frontend / scripts / check-theme-token-contract.mjs
1 #!/usr/bin/env node
2
3 import fs from "node:fs";
4 import path from "node:path";
5 import { fileURLToPath } from "node:url";
6
7 const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8 const frontendRoot = path.resolve(scriptDir, "..");
9 const sourceRoot = path.join(frontendRoot, "src");
10
11 const retiredTokens = new Set([
12 "--fg-muted",
13 "--bg-elev-1",
14 "--hover",
15 "--border-strong",
16 "--shadow",
17 ]);
18
19 // These properties are deliberately written by React/runtime geometry code.
20 // Preview tokens are isolated on ThemePreviewSurface and never form part of
21 // the application theme contract.
22 const runtimeTokens = new Set([
23 "--composer-height",
24 "--invocation-color",
25 "--sidebar-expanded-width",
26 "--transcript-row-estimate",
27 ]);
28 const runtimePrefixes = ["--tp-"];
29
30 const requiredRootTokens = new Map([
31 ["--stage", "var(--bg)"],
32 ["--surface", "var(--bg-elev)"],
33 ["--surface-2", "var(--bg-elev-2)"],
34 ["--surface-3", "var(--bg-soft)"],
35 ["--panel", "var(--bg-elev)"],
36 ["--border-2", "color-mix(in srgb, var(--fg) 20%, transparent)"],
37 ["--text", "var(--fg)"],
38 ["--text-2", "var(--fg-dim)"],
39 ["--text-3", "var(--fg-faint)"],
40 ["--shadow-color", "#000"],
41 ["--overlay-surface-bg", "var(--bg-elev)"],
42 ]);
43
44 const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
45 const amberExpected = {
46 dark: {
47 "--bg": "#090a0c",
48 "--bg-soft": "#111319",
49 "--panel": "#191b22",
50 "--sidebar-bg": "#0c0e12",
51 "--fg": "#f4f5f7",
52 "--fg-dim": "#c0c4cc",
53 "--accent": "#d4632f",
54 "--border": "#343945",
55 },
56 light: {
57 "--bg": "#f7f8fb",
58 "--bg-soft": "#eef2f7",
59 "--panel": "#ffffff",
60 "--sidebar-bg": "#f3f6fa",
61 "--fg": "#111827",
62 "--fg-dim": "#4b5563",
63 "--accent": "#dd5b28",
64 "--border": "#d8dee8",
65 },
66 };
67
68 function listCSSFiles(directory) {
69 const files = [];
70 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
71 const target = path.join(directory, entry.name);
72 if (entry.isDirectory()) files.push(...listCSSFiles(target));
73 else if (entry.isFile() && target.endsWith(".css")) files.push(target);
74 }
75 return files.sort();
76 }
77
78 function stripComments(source) {
79 return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
80 }
81
82 function lineNumber(source, index) {
83 return source.slice(0, index).split("\n").length;
84 }
85
86 function findBlock(source, selector) {
87 const start = source.indexOf(selector);
88 if (start < 0) return null;
89 const open = source.indexOf("{", start + selector.length - 1);
90 if (open < 0) return null;
91 let depth = 0;
92 for (let index = open; index < source.length; index += 1) {
93 if (source[index] === "{") depth += 1;
94 else if (source[index] === "}") {
95 depth -= 1;
96 if (depth === 0) return source.slice(open + 1, index);
97 }
98 }
99 return null;
100 }
101
102 function declarations(block) {
103 const result = new Map();
104 if (!block) return result;
105 for (const match of block.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g)) {
106 result.set(match[1], match[2].trim());
107 }
108 return result;
109 }
110
111 function normalize(value) {
112 return value.replace(/\s+/g, " ").trim().toLowerCase();
113 }
114
115 function resolveToken(name, tokens, seen = new Set()) {
116 if (seen.has(name)) return null;
117 const value = tokens.get(name);
118 if (!value) return null;
119 const alias = value.match(/^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/);
120 if (!alias) return normalize(value);
121 const nextSeen = new Set(seen);
122 nextSeen.add(name);
123 return resolveToken(alias[1], tokens, nextSeen);
124 }
125
126 const cssFiles = listCSSFiles(sourceRoot);
127 const sources = new Map(cssFiles.map((file) => [file, stripComments(fs.readFileSync(file, "utf8"))]));
128 const definitions = new Set();
129 const errors = [];
130
131 for (const source of sources.values()) {
132 for (const match of source.matchAll(/^\s*(--[a-zA-Z0-9_-]+)\s*:/gm)) definitions.add(match[1]);
133 for (const match of source.matchAll(/(?<=[{;])\s*(--[a-zA-Z0-9_-]+)\s*:/g)) definitions.add(match[1]);
134 }
135
136 for (const [file, source] of sources) {
137 for (const match of source.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?=,|\))/g)) {
138 const token = match[1];
139 const next = source[match.index + match[0].length];
140 const hasFallback = next === ",";
141 const runtimeOwned = runtimeTokens.has(token) || runtimePrefixes.some((prefix) => token.startsWith(prefix));
142 if (retiredTokens.has(token)) {
143 errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references retired ${token}`);
144 } else if (!definitions.has(token) && !hasFallback && !runtimeOwned) {
145 errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references undefined ${token}`);
146 }
147 }
148 }
149
150 const mainStyles = sources.get(path.join(sourceRoot, "styles.css"));
151 if (!mainStyles) {
152 errors.push("src/styles.css is missing");
153 } else {
154 const rootTokens = declarations(findBlock(mainStyles, ":root {"));
155 for (const [token, expected] of requiredRootTokens) {
156 const actual = rootTokens.get(token);
157 if (!actual) errors.push(`src/styles.css base :root is missing ${token}`);
158 else if (normalize(actual) !== normalize(expected)) {
159 errors.push(`src/styles.css base :root must define ${token}: ${expected}; (found ${actual})`);
160 }
161 }
162 for (const style of themeStyles) {
163 const darkSelector = `:root[data-theme-style="${style}"]`;
164 const lightSelector = `:root[data-theme="light"][data-theme-style="${style}"]`;
165 const autoLightSelector = `:root[data-theme-style="${style}"]:not([data-theme])`;
166 if (!mainStyles.includes(`${darkSelector} {`)) errors.push(`src/styles.css is missing ${style} dark theme selector`);
167 if (!mainStyles.includes(`${lightSelector} {`)) errors.push(`src/styles.css is missing ${style} forced-light selector`);
168 if (!mainStyles.includes(`${autoLightSelector} {`)) errors.push(`src/styles.css is missing ${style} auto-light selector`);
169 }
170
171 const workbenchRefresh = mainStyles.slice(mainStyles.indexOf("* Native Workbench refresh"));
172 if (/^:root\s*\{/m.test(workbenchRefresh)) {
173 errors.push("Native Workbench refresh must not override named dark theme palettes");
174 }
175 if (/^:root\[data-theme="light"\]\s*\{/m.test(workbenchRefresh)) {
176 errors.push("Native Workbench refresh must not override named forced-light theme palettes");
177 }
178 if (/^\s*:root:not\(\[data-theme\]\)\s*\{/m.test(workbenchRefresh)) {
179 errors.push("Native Workbench refresh must not override named auto-light theme palettes");
180 }
181
182 const darkAmber = new Map([
183 ...rootTokens,
184 ...declarations(findBlock(mainStyles, ':root[data-theme-style="amber"] {')),
185 ]);
186 const lightAmber = new Map([
187 ...rootTokens,
188 ...declarations(findBlock(mainStyles, ':root[data-theme="light"] {')),
189 ...declarations(findBlock(mainStyles, ':root[data-theme="light"][data-theme-style="amber"] {')),
190 ]);
191 for (const [mode, tokens] of [["dark", darkAmber], ["light", lightAmber]]) {
192 for (const [token, expected] of Object.entries(amberExpected[mode])) {
193 const actual = resolveToken(token, tokens);
194 if (actual !== normalize(expected)) {
195 errors.push(`Amber ${mode} ${token} must resolve to ${expected} (found ${actual ?? "undefined"})`);
196 }
197 }
198 }
199 }
200
201 if (errors.length > 0) {
202 console.error("Theme token contract check failed:");
203 for (const error of errors) console.error(`- ${error}`);
204 process.exit(1);
205 }
206
207 console.log(`Theme token contract OK (${cssFiles.length} CSS files, ${definitions.size} defined tokens).`);
208
208 lines Plain Text