| 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 |