diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index 729363dba..2ce3d85fb 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -66,11 +66,12 @@ registry。 | 指标 | 当前基线 | | --- | ---: | -| 扫描的生产前端文件数 | 1536 | +| 扫描的生产前端文件数 | 1535 | | 忽略的测试文件数 | 221 | -| 包含颜色字面量的文件数 | 26 | -| 颜色字面量出现次数 | 1717 | -| 唯一颜色字面量数量 | 913 | +| 忽略的构建生成文件数 | 0 | +| 包含颜色字面量的文件数 | 25 | +| 颜色字面量出现次数 | 1702 | +| 唯一颜色字面量数量 | 912 | | 组件或非 token 文件中的颜色出现次数 | 0 | | 组件或非 token 唯一颜色数量 | 0 | | App UI 颜色出现次数 | 0 | @@ -81,6 +82,8 @@ registry。 | token-equivalent app literal 唯一颜色数量 | 0 | | 普通组件肉眼不可区分 near color pair | 0 | | 普通组件需证据复核的 near color pair | 0 | +| 专用域肉眼不可区分 near color pair | 35 | +| 专用域需证据复核的 near color pair | 644 | 当前审计未发现 CSS 变量契约层面的硬错误: @@ -115,6 +118,7 @@ registry。 | generated widget payload key | 326 | widget iframe 对外主题变量 allowlist,作为外部边界单独预算,不计入内部 alias 读取 | | generated widget payload compatibility alias | 64 | payload 仍暴露已登记 legacy alias,防止已生成或第三方内容读取失败 | | generated widget payload compatibility family key | 17 | payload 仍暴露 `--radius-*`、`--spacing-*` 具体 key,同时暴露 canonical family | +| generated widget payload external-only compatibility key | 81 | payload 中仍保留且内部产品代码不再读取的 legacy key,是后续外部兼容评估和收缩队列 | | generated widget payload undefined key | 0 | 防止 payload 引入没有静态、运行时或动态 family 定义的游离 key | | generated widget payload missing compatibility canonical | 0 | 防止 payload 暴露 legacy key 但 canonical 目标不存在 | | generated widget payload unexported compatibility canonical | 0 | 防止 payload 暴露 legacy key 但遗漏对应 canonical key | @@ -128,7 +132,7 @@ registry。 | 区域 | 当前出现次数 | 当前唯一色数 | 说明 | | --- | ---: | ---: | --- | -| Theme presets | 1033 | 611 | 主题个性与 palette 映射,不作为普通 app literal 直接合并 | +| Theme presets | 1019 | 611 | 主题个性与 palette 映射,不作为普通 app literal 直接合并;git staged 仅在与 added 视觉语义相同时由 preset helper 复用 | | Token contracts | 267 | 159 | `tokens.scss` 等静态契约根 | | Editor | 56 | 53 | Monaco/editor 专用域,不能直接泛化到 app token;组件装饰色已迁出 raw literal | | Mermaid | 139 | 95 | Mermaid 专用渲染域 | @@ -142,16 +146,39 @@ registry。 | App UI | 0 | 0 | 普通 app/component raw color 已清零;后续新增必须先进入 token/exception 决策 | | Syntax | 18 | 17 | Prism syntax palette,保留为专用渲染域 | +专用域 near color pair 已单独进入证据队列,避免把 editor、terminal、Mermaid、 +theme preset 或 boundary fallback 误算成普通 app UI 债务。当前队列不是自动合并指令, +而是后续截图和语义复核的候选清单: + +| 专用域 | 肉眼不可区分 pair | 需证据复核 pair | 后续处理原则 | +| --- | ---: | ---: | --- | +| Theme presets | 30 | 459 | 优先处理同一 theme 内 hex/rgb 完全等价和同 alpha 阶重复;保留主题个性 | +| Theme runtime | 0 | 50 | 先与静态 token 和 payload contract 对齐,避免 early render 或 system theme 回退 | +| Token contracts | 2 | 90 | 优先 alias 精确等价值;状态、层级和 alpha ramp 不按数值强合并 | +| Boundary fallback | 0 | 6 | 只在 iframe/截图 first paint 验证后收缩 | +| Mermaid | 3 | 32 | 需检查节点、边、文本、错误态和 light/dark Mermaid 输出 | +| Editor | 0 | 7 | 需检查 Monaco selection、diff、inline highlight 和 light/dark editor 可读性 | +| Syntax / Terminal / Generated widget / Debug overlay / UI exception / Language identity / Visual effects | 0 | 0 | 当前无 near 队列;新增会被单域 baseline 拦截 | + 剩余高频文件均为专用 palette 或集中 registry: | 文件 | 颜色出现次数 | 后续处理策略 | | --- | ---: | --- | -| `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 47 | Monaco theme palette;不拆散到普通 app token | +| `src/web-ui/src/component-library/styles/tokens.scss` | 242 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 | +| `src/web-ui/src/infrastructure/theme/presets/cyber-theme.ts` | 145 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts` | 145 | theme preset palette;Tokyo git staged 与 added 语义不同,保留显式覆盖 | +| `src/web-ui/src/infrastructure/theme/presets/china-style-theme.ts` | 128 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/light-theme.ts` | 128 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/china-night-theme.ts` | 122 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts` | 120 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/dark-theme.ts` | 109 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/infrastructure/theme/presets/slate-theme.ts` | 109 | theme preset palette;保留主题个性,优先继续收敛同一结构下的机械重复 | +| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 106 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 | +| `src/web-ui/src/infrastructure/theme/core/ThemeService.ts` | 54 | 运行时注入;需保持 early render、system theme 和 payload 导出兼容 | | `src/web-ui/src/shared/theme/languageIdentityAccents.ts` | 52 | 内置 language/file identity registry;调用方复用常量 | +| `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 47 | Monaco theme palette;不拆散到普通 app token | | `src/web-ui/src/shared/theme/uiExceptionAccents.ts` | 38 | 固定 UI 身份/角色色 registry;新增必须说明 owner/role | | `src/web-ui/src/tools/terminal/utils/xtermTheme.ts` | 36 | terminal ANSI palette;不与 app semantic color 合并 | -| `src/web-ui/src/shared/theme/themeBoundaryFallbacks.ts` | 22 | isolated surface 和截图兜底值;集中 owner | -| `src/web-ui/src/shared/theme/syntaxHighlightAccents.ts` | 18 | Prism syntax palette;不泛化到 app token | 组件级 `var(--token, fallback)` 已收敛到 0;原先的 7 个 fallback token 不再需要 fallback contract registry 保留。 @@ -177,8 +204,8 @@ fallback 收敛决策表: | Phase 2:精确重复合并 | 已完成主体 | token-equivalent app literal 已清零;截图兜底、language identity 和 review/agent/insights 固定色已迁入显式 registry;`tokens.scss` 中同域同语义的 deep-review consent 精确重复已改为 alias | | Phase 3:legacy fallback 迁移 | 已完成主体 | 组件级 `var(--token, fallback)` 已清零,baseline 降到 0;新增 fallback 会被审计报告和 baseline 拦截 | | Phase 4:组件 token 抽取 | 已完成主体 | CodeEditor、StreamText、ChatInputPixelPet、ReferencesPanel、AgentCompanion、tool-card、editor、generative-widget 组件装饰色已抽为组件 token 或复用 contract token;Flow Chat 局部动态 key 已改为 surface namespace | -| Phase 5:近似色合并 | 已完成主体 | 普通组件 near pair 已清零;极近似视觉色只在不相邻或不承担状态差异时合并,Monaco/terminal/Mermaid/syntax 专用 palette 不强行合并 | -| Phase 6:防回退约束 | 已强化 | baseline 已同步到 component/non-token=0、appUi=0、token-equivalent=0、nearPair=0、compatibility alias 读取=0、fallback=0、surface rename debt=0,并保留 generated widget payload、domain contract 防回退指标 | +| Phase 5:近似色合并 | 已完成主体并进入专用域证据队列 | 普通组件 near pair 已清零;极近似视觉色只在不相邻或不承担状态差异时合并,Monaco/terminal/Mermaid/syntax/theme preset 等专用 palette 通过 `colorDomainNearPairs.*` 排队复核 | +| Phase 6:防回退约束 | 已强化 | baseline 已同步到 component/non-token=0、appUi=0、token-equivalent=0、ordinary nearPair=0、compatibility alias 读取=0、fallback=0、surface rename debt=0,并保留 generated widget payload、external-only compatibility、domain contract 和专用域 near-pair 防回退指标 | Phase 5 决策记录: @@ -203,6 +230,8 @@ Phase 6 防回退约束: | --- | ---: | ---: | --- | | `nearPairs.indistinguishableTotal` | 0 | 0 | 阻止新增普通组件肉眼不可区分 pair 未被合并或记录 | | `nearPairs.nearTotal` | 0 | 0 | 阻止新增普通组件 near color 债务;新增必须合并、归类或记录理由 | +| `colorDomainNearPairs.indistinguishableTotal` | 35 | 35 | 控制专用域肉眼不可区分 pair 不继续增长,后续只能逐步降低或补充证据 | +| `colorDomainNearPairs.nearTotal` | 644 | 644 | 控制 theme preset/runtime/token/editor/Mermaid 等专用域 near 队列规模 | | `colorScopes.appUi.uniqueColors` | 0 | 0 | 阻止普通组件 raw color 唯一色回涨 | | `colorScopes.appUi.occurrences` | 0 | 0 | 阻止普通组件 raw color 出现次数回涨 | | `tokenAliasLiterals.occurrences` | 0 | 0 | 阻止重新出现可映射到 token 的 app literal | @@ -221,6 +250,7 @@ Phase 6 防回退约束: | `generatedWidgetPayload.varUnique` | 326 | 326 | 控制 widget 对外主题 payload allowlist 不继续膨胀 | | `generatedWidgetPayload.compatibilityAliasUnique` | 64 | 64 | 控制 payload 中显式 legacy alias 数量,后续只能降低或经复审调整 | | `generatedWidgetPayload.compatibilityAliasFamilyUnique` | 17 | 17 | 控制 payload 中 legacy size family 具体 key 数量 | +| `generatedWidgetPayload.externalOnlyCompatibilityUnique` | 81 | 81 | 标记 payload 中内部代码已不读取、仅因外部兼容保留的收缩候选 | | `generatedWidgetPayload.undefinedUnique` | 0 | 0 | 防止 payload 导出未定义主题 key | | `generatedWidgetPayload.missingCompatibilityCanonicalUnique` | 0 | 0 | 防止 payload 兼容 alias 缺失 canonical 目标 | | `generatedWidgetPayload.unexportedCompatibilityCanonicalUnique` | 0 | 0 | 防止 payload 兼容 alias 有 canonical 定义但未导出到 iframe | @@ -230,7 +260,8 @@ Phase 6 防回退约束: `nearPairs.*` 只基于非 token、非 exception 的普通组件颜色计算。Theme preset、 editor、syntax、terminal、language identity、boundary fallback 等专用域通过各自 -`colorDomainScopes.*` 预算约束,不用该 near-pair guard 直接判定是否可合并。 +`colorDomainScopes.*` 和 `colorDomainNearPairs.*` 预算约束。专用域 near 队列用于 +安排截图和语义复核,不直接判定是否可合并。 视觉证据契约新增在 `scripts/theme-visual-governance-contract.json`,并由 `pnpm run theme:visual-contract` 校验。它不是截图替代品,而是后续 PR 的覆盖面 @@ -847,7 +878,8 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 建议按证据和 surface 拆分,避免一次性大迁移: -已完成的治理批次覆盖前 9 项,并把 widget payload 外部兼容面纳入审计预算: +已完成的治理批次覆盖前 9 项,并把第 10/11 项所需的专用域 near 队列、 +widget payload external-only 兼容面和 raw color 防回退指标纳入审计预算: 1. 审计工具和 baseline report。 2. canonical token map 与 compatibility alias。 diff --git a/scripts/audit-theme-colors.mjs b/scripts/audit-theme-colors.mjs index b9faadbef..ccdd30245 100644 --- a/scripts/audit-theme-colors.mjs +++ b/scripts/audit-theme-colors.mjs @@ -173,6 +173,13 @@ function isAuditTestFile(relativePath) { ); } +function isGeneratedBuildArtifact(rootRelativePath) { + return ( + rootRelativePath === 'generated/version.ts' + || rootRelativePath === 'generated/version-injection.html' + ); +} + function isTokenFile(relativePath) { return TOKEN_PATH_PARTS.some(part => relativePath.includes(part)); } @@ -722,7 +729,22 @@ function audit(options) { const checksFullThemeSourceRoot = root === path.resolve(DEFAULT_ROOT); const files = walkFiles(root); const cwd = process.cwd(); - const auditedFiles = files.filter(file => !isAuditTestFile(normalizePath(path.relative(cwd, file)))); + const fileEntries = files.map(file => ({ + file, + relativePath: normalizePath(path.relative(cwd, file)), + rootRelativePath: normalizePath(path.relative(root, file)), + })); + const ignoredTestFiles = fileEntries.filter(entry => isAuditTestFile(entry.relativePath)); + const ignoredGeneratedFiles = fileEntries.filter(entry => ( + !isAuditTestFile(entry.relativePath) + && isGeneratedBuildArtifact(entry.rootRelativePath) + )); + const auditedFiles = fileEntries + .filter(entry => ( + !isAuditTestFile(entry.relativePath) + && !isGeneratedBuildArtifact(entry.rootRelativePath) + )) + .map(entry => entry.file); const tokenAliasDefinitionsByColorKey = collectTokenAliasDefinitions(auditedFiles, cwd); const colorCounts = new Map(); @@ -746,6 +768,7 @@ function audit(options) { const tokenColorCounts = new Map(); const colorDomainCounts = new Map(); const colorDomainFiles = new Map(); + const colorDomainColorFiles = new Map(); const tokenAliasLiteralCounts = new Map(); const tokenAliasLiteralFiles = new Map(); const tokenAliasLiteralExamples = new Map(); @@ -775,6 +798,9 @@ function audit(options) { const domainCounts = colorDomainCounts.get(colorDomain) ?? new Map(); incrementMap(domainCounts, color); colorDomainCounts.set(colorDomain, domainCounts); + const domainColorFiles = colorDomainColorFiles.get(colorDomain) ?? new Map(); + addToSetMap(domainColorFiles, color, relativePath); + colorDomainColorFiles.set(colorDomain, domainColorFiles); if (tokenFile) { incrementMap(tokenColorCounts, color); } else if (exceptionFile) { @@ -959,6 +985,23 @@ function audit(options) { topColors: topEntries(counts, options.top), }]; })); + const colorDomainNearPairEntries = Object.fromEntries(COLOR_DOMAIN_KEYS.map(key => { + const counts = colorDomainCounts.get(key) ?? new Map(); + const filesByColor = colorDomainColorFiles.get(key) ?? new Map(); + return [key, buildNearColorPairs(counts, filesByColor)]; + })); + const specializedColorDomainKeys = COLOR_DOMAIN_KEYS.filter(key => key !== 'appUi'); + const colorDomainNearPairs = { + indistinguishableTotal: specializedColorDomainKeys.reduce( + (total, key) => total + colorDomainNearPairEntries[key].indistinguishableTotal, + 0, + ), + nearTotal: specializedColorDomainKeys.reduce( + (total, key) => total + colorDomainNearPairEntries[key].nearTotal, + 0, + ), + ...colorDomainNearPairEntries, + }; const fallbackVars = Array.from(fallbackTokenCounts.entries()) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([key, count]) => ({ @@ -1033,6 +1076,12 @@ function audit(options) { ...entry, canonical: contract.canonical, canonicalDefinitionKind: getExplicitDefinitionKind(contract.canonical), + familyPrefix: null, + canonicalPrefix: null, + owner: contract.owner, + reason: contract.reason, + removal: contract.removal, + internalUsageCount: varUsageCounts.get(entry.key) ?? 0, }; }) .filter(Boolean) @@ -1049,10 +1098,31 @@ function audit(options) { familyPrefix: contract.familyPrefix, canonicalPrefix: contract.canonicalPrefix, canonicalDefinitionKind: getExplicitDefinitionKind(contract.canonical), + owner: contract.owner, + reason: contract.reason, + removal: contract.removal, + internalUsageCount: varUsageCounts.get(entry.key) ?? 0, }; }) .filter(Boolean) .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); + const generatedWidgetPayloadExternalOnlyCompatibility = [ + ...generatedWidgetPayloadCompatibilityAliases, + ...generatedWidgetPayloadCompatibilityFamilies, + ] + .filter(entry => entry.internalUsageCount === 0) + .map(entry => ({ + key: entry.key, + canonical: entry.canonical, + familyPrefix: entry.familyPrefix, + canonicalPrefix: entry.canonicalPrefix, + count: entry.count, + owner: entry.owner, + reason: entry.reason, + removal: entry.removal, + canonicalDefinitionKind: entry.canonicalDefinitionKind, + })) + .sort((a, b) => a.key.localeCompare(b.key)); const generatedWidgetPayloadUndefinedVars = generatedWidgetPayloadVars .filter(entry => !entry.definitionKind) .sort((a, b) => a.key.localeCompare(b.key)); @@ -1149,7 +1219,8 @@ function audit(options) { return { root: normalizePath(path.relative(cwd, root)) || '.', filesScanned: auditedFiles.length, - ignoredTestFiles: files.length - auditedFiles.length, + ignoredTestFiles: ignoredTestFiles.length, + ignoredGeneratedFiles: ignoredGeneratedFiles.length, filesWithColors: fileColorCounts.size, colorOccurrences, uniqueColors: colorCounts.size, @@ -1169,6 +1240,7 @@ function audit(options) { }, }, colorDomainScopes, + colorDomainNearPairs, componentColorOccurrences, componentFilesWithColors: componentFileColorCounts.size, uniqueComponentColors, @@ -1220,10 +1292,16 @@ function audit(options) { (total, entry) => total + entry.count, 0, ), + externalOnlyCompatibilityUnique: generatedWidgetPayloadExternalOnlyCompatibility.length, + externalOnlyCompatibilityOccurrences: generatedWidgetPayloadExternalOnlyCompatibility.reduce( + (total, entry) => total + entry.count, + 0, + ), missingCompatibilityCanonicalUnique: generatedWidgetPayloadMissingCompatibilityCanonicals.length, unexportedCompatibilityCanonicalUnique: generatedWidgetPayloadUnexportedCompatibilityCanonicals.length, topCompatibilityAliases: generatedWidgetPayloadCompatibilityAliases.slice(0, options.top), topCompatibilityFamilies: generatedWidgetPayloadCompatibilityFamilies.slice(0, options.top), + externalOnlyCompatibility: generatedWidgetPayloadExternalOnlyCompatibility.slice(0, REPORT_ROW_LIMIT), undefinedVars: generatedWidgetPayloadUndefinedVars.slice(0, REPORT_ROW_LIMIT), missingCompatibilityCanonicals: generatedWidgetPayloadMissingCompatibilityCanonicals.slice(0, REPORT_ROW_LIMIT), unexportedCompatibilityCanonicals: generatedWidgetPayloadUnexportedCompatibilityCanonicals.slice( @@ -1299,6 +1377,7 @@ function printText(report) { console.log(`Theme color audit: ${report.root}`); console.log(`Files scanned: ${report.filesScanned}`); console.log(`Ignored test files: ${report.ignoredTestFiles}`); + console.log(`Ignored generated files: ${report.ignoredGeneratedFiles}`); console.log(`Files with colors: ${report.filesWithColors}`); console.log(`Color occurrences: ${report.colorOccurrences}`); console.log(`Unique colors: ${report.uniqueColors}`); @@ -1325,6 +1404,7 @@ function printText(report) { `undefined=${report.generatedWidgetPayload.undefinedUnique}, ` + `compatAliases=${report.generatedWidgetPayload.compatibilityAliasUnique}, ` + `compatAliasFamilies=${report.generatedWidgetPayload.compatibilityAliasFamilyUnique}, ` + + `externalOnlyCompat=${report.generatedWidgetPayload.externalOnlyCompatibilityUnique}, ` + `missingCompatCanonicals=${report.generatedWidgetPayload.missingCompatibilityCanonicalUnique}, ` + `unexportedCompatCanonicals=${report.generatedWidgetPayload.unexportedCompatibilityCanonicalUnique}` ); @@ -1366,6 +1446,38 @@ function printText(report) { ); } + console.log('\nSpecialized color-domain near pairs:'); + let printedDomainNearPairs = false; + for (const key of COLOR_DOMAIN_KEYS.filter(domainKey => domainKey !== 'appUi')) { + const pairs = report.colorDomainNearPairs[key]; + if (!pairs || (pairs.indistinguishableTotal === 0 && pairs.nearTotal === 0)) { + continue; + } + printedDomainNearPairs = true; + console.log( + ` ${COLOR_DOMAIN_LABELS[key].padEnd(18)} ` + + `indistinguishable=${pairs.indistinguishableTotal.toString().padStart(4)} ` + + `near=${pairs.nearTotal.toString().padStart(4)}` + ); + for (const pair of pairs.indistinguishable.slice(0, 3)) { + console.log( + ` indistinguishable ${pair.a} <-> ${pair.b} ` + + `distance=${pair.distance.toFixed(2)} alphaDiff=${pair.alphaDiff.toFixed(3)} ` + + `combined=${pair.count}` + ); + } + for (const pair of pairs.near.slice(0, 3)) { + console.log( + ` near ${pair.a} <-> ${pair.b} ` + + `distance=${pair.distance.toFixed(2)} alphaDiff=${pair.alphaDiff.toFixed(3)} ` + + `combined=${pair.count}` + ); + } + } + if (!printedDomainNearPairs) { + console.log(' none'); + } + console.log('\nTop files:'); console.log(printRows(report.topFiles)); @@ -1455,6 +1567,19 @@ function printText(report) { } } + console.log('\nGenerated widget payload external-only compatibility:'); + if (report.generatedWidgetPayload.externalOnlyCompatibility.length === 0) { + console.log(' none'); + } else { + for (const row of report.generatedWidgetPayload.externalOnlyCompatibility.slice(0, 10)) { + const family = row.familyPrefix ? ` family=${row.familyPrefix}*` : ''; + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} -> ${row.canonical}${family} ` + + `canonicalDefined=${Boolean(row.canonicalDefinitionKind)}` + ); + } + } + console.log('\nGenerated widget payload undefined vars:'); console.log(printRows(report.generatedWidgetPayload.undefinedVars.slice(0, 10))); diff --git a/scripts/audit-theme-colors.test.mjs b/scripts/audit-theme-colors.test.mjs index b279f49c2..bbdeb3b97 100644 --- a/scripts/audit-theme-colors.test.mjs +++ b/scripts/audit-theme-colors.test.mjs @@ -350,6 +350,7 @@ test('theme color audit budgets generated widget payload compatibility aliases s assert.equal(report.generatedWidgetPayload.varUnique, 4); assert.equal(report.generatedWidgetPayload.compatibilityAliasUnique, 1); assert.equal(report.generatedWidgetPayload.compatibilityAliasFamilyUnique, 1); + assert.equal(report.generatedWidgetPayload.externalOnlyCompatibilityUnique, 2); assert.equal(report.generatedWidgetPayload.undefinedUnique, 0); assert.equal(report.generatedWidgetPayload.missingCompatibilityCanonicalUnique, 0); assert.equal(report.generatedWidgetPayload.unexportedCompatibilityCanonicalUnique, 0); @@ -361,6 +362,14 @@ test('theme color audit budgets generated widget payload compatibility aliases s report.generatedWidgetPayload.topCompatibilityFamilies.map(row => [row.key, row.canonical]), [['--radius-sm', '--size-radius-sm']], ); + assert.deepEqual( + report.generatedWidgetPayload.externalOnlyCompatibility.map(row => [row.key, row.canonical, row.familyPrefix]), + [ + ['--color-primary', '--color-accent-500', null], + ['--radius-sm', '--size-radius-sm', '--radius-'], + ], + ); + assert.match(report.generatedWidgetPayload.externalOnlyCompatibility[0].removal, /Retire/); }); test('theme color audit reports generated widget payload compatibility aliases without canonicals', (t) => { @@ -658,12 +667,48 @@ test('theme color audit reports near color pair sources and enforces pair budget ); }); +test('theme color audit reports near color pairs inside specialized color domains', (t) => { + const { dir, sourceRoot } = createFixture({ + 'tools/mermaid-editor/theme/mermaidTheme.ts': [ + "export const lightNode = '#111111';", + "export const darkNode = '#111112';", + '', + ].join('\n'), + 'tools/terminal/utils/xtermTheme.ts': [ + "export const normalBlack = '#222222';", + "export const brightBlack = '#222225';", + '', + ].join('\n'), + 'app/App.scss': '.app { color: #333333; }\n', + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const reportResult = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(reportResult.status, 0, reportResult.stderr || reportResult.stdout); + assert.match(reportResult.stdout, /Specialized color-domain near pairs:/); + + const report = readJson(reportPath); + assert.equal(report.colorDomainNearPairs.mermaid.indistinguishableTotal, 1); + assert.equal(report.colorDomainNearPairs.terminal.nearTotal, 1); + assert.equal(report.colorDomainNearPairs.appUi.indistinguishableTotal, 0); + assert.equal(report.colorDomainNearPairs.indistinguishableTotal, 1); + assert.equal(report.colorDomainNearPairs.nearTotal, 1); + assert.deepEqual( + report.colorDomainNearPairs.mermaid.indistinguishable[0].files.map(file => ( + file.replace(/\\/g, '/').split('/').slice(-3).join('/') + )), + ['mermaid-editor/theme/mermaidTheme.ts'], + ); +}); + test('theme color audit excludes test files from production color budgets', (t) => { const { dir, sourceRoot } = createFixture({ 'component-library/styles/tokens.scss': ':root { --color-error: #ef4444; }\n', 'app/App.scss': '.app { color: #ef4444; }\n', 'app/App.test.tsx': "expect(button).toHaveStyle({ color: '#ef4444' });\n", 'app/__tests__/Fixture.tsx': "export const visualLock = '#ef4444';\n", + 'generated/version.ts': "export const buildAccent = '#22c55e';\n", }); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); @@ -673,6 +718,7 @@ test('theme color audit excludes test files from production color budgets', (t) const report = JSON.parse(result.stdout); assert.equal(report.filesScanned, 2); assert.equal(report.ignoredTestFiles, 2); + assert.equal(report.ignoredGeneratedFiles, 1); assert.equal(report.colorScopes.appUi.occurrences, 1); assert.equal(report.tokenAliasLiterals.occurrences, 1); }); diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index 68438ffbd..ac4570f6d 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -74,6 +74,12 @@ "generatedWidgetPayload.compatibilityAliasFamilyOccurrences": { "max": 17 }, + "generatedWidgetPayload.externalOnlyCompatibilityUnique": { + "max": 81 + }, + "generatedWidgetPayload.externalOnlyCompatibilityOccurrences": { + "max": 81 + }, "generatedWidgetPayload.missingCompatibilityCanonicalUnique": { "max": 0 }, @@ -140,8 +146,98 @@ "nearPairs.nearTotal": { "max": 0 }, + "colorDomainNearPairs.indistinguishableTotal": { + "max": 35 + }, + "colorDomainNearPairs.nearTotal": { + "max": 644 + }, + "colorDomainNearPairs.themePreset.indistinguishableTotal": { + "max": 30 + }, + "colorDomainNearPairs.themePreset.nearTotal": { + "max": 459 + }, + "colorDomainNearPairs.themeRuntime.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.themeRuntime.nearTotal": { + "max": 50 + }, + "colorDomainNearPairs.tokenContract.indistinguishableTotal": { + "max": 2 + }, + "colorDomainNearPairs.tokenContract.nearTotal": { + "max": 90 + }, + "colorDomainNearPairs.generatedWidget.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.generatedWidget.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.boundaryFallback.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.boundaryFallback.nearTotal": { + "max": 6 + }, + "colorDomainNearPairs.mermaid.indistinguishableTotal": { + "max": 3 + }, + "colorDomainNearPairs.mermaid.nearTotal": { + "max": 32 + }, + "colorDomainNearPairs.editor.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.editor.nearTotal": { + "max": 7 + }, + "colorDomainNearPairs.syntax.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.syntax.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.terminal.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.terminal.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.debugOverlay.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.debugOverlay.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.uiException.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.uiException.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.languageIdentity.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.languageIdentity.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.visualEffect.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.visualEffect.nearTotal": { + "max": 0 + }, + "colorDomainNearPairs.appUi.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.appUi.nearTotal": { + "max": 0 + }, "colorDomainScopes.themePreset.occurrences": { - "max": 1033 + "max": 1019 }, "colorDomainScopes.themePreset.uniqueColors": { "max": 611 diff --git a/src/web-ui/src/infrastructure/theme/presets/china-night-theme.ts b/src/web-ui/src/infrastructure/theme/presets/china-night-theme.ts index e75676893..8989cd1dc 100644 --- a/src/web-ui/src/infrastructure/theme/presets/china-night-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/china-night-theme.ts @@ -4,6 +4,7 @@ import { ThemeConfig } from '../types'; import { createChinaTypography, createCompactRadius, + createGitColors, createStandardEasing, createStandardSpacing, createWindowControls, @@ -101,7 +102,7 @@ export const bitfunChinaNightTheme: ThemeConfig = { elevated: 'rgba(45, 41, 38, 0.95)', }, - git: { + git: createGitColors({ branch: 'rgb(115, 165, 204)', branchBg: 'rgba(115, 165, 204, 0.12)', changes: 'rgb(245, 181, 85)', @@ -110,9 +111,7 @@ export const bitfunChinaNightTheme: ThemeConfig = { addedBg: 'rgba(107, 192, 114, 0.12)', deleted: 'rgb(232, 85, 85)', deletedBg: 'rgba(232, 85, 85, 0.12)', - staged: 'rgb(107, 192, 114)', - stagedBg: 'rgba(107, 192, 114, 0.12)', - }, + }), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/china-style-theme.ts b/src/web-ui/src/infrastructure/theme/presets/china-style-theme.ts index 6a94e739a..dd4917f7e 100644 --- a/src/web-ui/src/infrastructure/theme/presets/china-style-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/china-style-theme.ts @@ -4,6 +4,7 @@ import { ThemeConfig } from '../types'; import { createChinaTypography, createCompactRadius, + createGitColors, createStandardEasing, createStandardSpacing, createWindowControls, @@ -99,7 +100,7 @@ export const bitfunChinaStyleTheme: ThemeConfig = { elevated: 'rgba(255, 255, 255, 0.85)', }, - git: { + git: createGitColors({ branch: 'rgb(46, 94, 138)', branchBg: 'rgba(46, 94, 138, 0.08)', changes: 'rgb(240, 160, 32)', @@ -108,9 +109,7 @@ export const bitfunChinaStyleTheme: ThemeConfig = { addedBg: 'rgba(82, 173, 90, 0.08)', deleted: 'rgb(200, 16, 46)', deletedBg: 'rgba(200, 16, 46, 0.08)', - staged: 'rgb(82, 173, 90)', - stagedBg: 'rgba(82, 173, 90, 0.08)', - }, + }), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/cyber-theme.ts b/src/web-ui/src/infrastructure/theme/presets/cyber-theme.ts index d14ba916a..5537154e7 100644 --- a/src/web-ui/src/infrastructure/theme/presets/cyber-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/cyber-theme.ts @@ -4,6 +4,7 @@ import { ThemeConfig } from '../types'; import { createCompactRadius, createExpressiveTypography, + createGitColors, createStandardEasing, createStandardSpacing, createWindowControls, @@ -101,7 +102,7 @@ export const bitfunCyberTheme: ThemeConfig = { elevated: 'rgba(0, 230, 255, 0.27)', }, - git: { + git: createGitColors({ branch: 'rgb(0, 230, 255)', branchBg: 'rgba(0, 230, 255, 0.12)', changes: 'rgb(255, 204, 0)', @@ -110,9 +111,7 @@ export const bitfunCyberTheme: ThemeConfig = { addedBg: 'rgba(0, 255, 159, 0.12)', deleted: 'rgb(255, 0, 85)', deletedBg: 'rgba(255, 0, 85, 0.12)', - staged: 'rgb(0, 255, 159)', - stagedBg: 'rgba(0, 255, 159, 0.12)', - }, + }), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/dark-theme.ts b/src/web-ui/src/infrastructure/theme/presets/dark-theme.ts index 8d5841b23..34a75da1c 100644 --- a/src/web-ui/src/infrastructure/theme/presets/dark-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/dark-theme.ts @@ -5,6 +5,7 @@ import { createDarkNeutralBorder, createDarkNeutralElement, createDarkNeutralScrollbar, + createGitColors, createStandardEasing, createStandardRadius, createStandardSpacing, @@ -91,7 +92,7 @@ export const bitfunDarkTheme: ThemeConfig = { element: createDarkNeutralElement(), - git: { + git: createGitColors({ branch: '#a1a1aa', branchBg: 'rgba(255, 255, 255, 0.06)', changes: 'rgb(245, 158, 11)', @@ -100,9 +101,7 @@ export const bitfunDarkTheme: ThemeConfig = { addedBg: 'rgba(34, 197, 94, 0.1)', deleted: 'rgb(239, 68, 68)', deletedBg: 'rgba(239, 68, 68, 0.1)', - staged: 'rgb(34, 197, 94)', - stagedBg: 'rgba(34, 197, 94, 0.1)', - }, + }), scrollbar: createDarkNeutralScrollbar(), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/light-theme.ts b/src/web-ui/src/infrastructure/theme/presets/light-theme.ts index 349c6b5c3..f355a75dc 100644 --- a/src/web-ui/src/infrastructure/theme/presets/light-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/light-theme.ts @@ -2,6 +2,7 @@ import { ThemeConfig } from '../types'; import { + createGitColors, createStandardEasing, createStandardRadius, createStandardSpacing, @@ -111,7 +112,7 @@ export const bitfunLightTheme: ThemeConfig = { }, - git: { + git: createGitColors({ branch: 'rgb(71, 85, 105)', branchBg: 'rgba(71, 85, 105, 0.1)', changes: 'rgb(192, 140, 66)', @@ -120,9 +121,7 @@ export const bitfunLightTheme: ThemeConfig = { addedBg: 'rgba(91, 154, 111, 0.08)', deleted: 'rgb(194, 101, 101)', deletedBg: 'rgba(194, 101, 101, 0.08)', - staged: 'rgb(91, 154, 111)', - stagedBg: 'rgba(91, 154, 111, 0.08)', - }, + }), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts b/src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts index 68e5c4231..f5a5a6bbb 100644 --- a/src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts @@ -2,6 +2,7 @@ import { ThemeConfig } from '../types'; import { + createGitColors, createStandardEasing, createStandardRadius, createStandardSpacing, @@ -101,7 +102,7 @@ export const bitfunMidnightTheme: ThemeConfig = { elevated: 'rgba(255, 255, 255, 0.18)', }, - git: { + git: createGitColors({ branch: 'rgb(88, 166, 255)', branchBg: 'rgba(88, 166, 255, 0.1)', changes: 'rgb(224, 160, 85)', @@ -110,9 +111,7 @@ export const bitfunMidnightTheme: ThemeConfig = { addedBg: 'rgba(106, 171, 115, 0.1)', deleted: 'rgb(204, 127, 122)', deletedBg: 'rgba(204, 127, 122, 0.1)', - staged: 'rgb(106, 171, 115)', - stagedBg: 'rgba(106, 171, 115, 0.1)', - }, + }), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/shared.ts b/src/web-ui/src/infrastructure/theme/presets/shared.ts index bbb57b497..243d45307 100644 --- a/src/web-ui/src/infrastructure/theme/presets/shared.ts +++ b/src/web-ui/src/infrastructure/theme/presets/shared.ts @@ -1,6 +1,7 @@ import type { BorderColors, ElementBackgrounds, + GitColors, RadiusConfig, ScrollbarColors, ThemeConfig, @@ -174,6 +175,16 @@ export function createDarkNeutralElement(): ElementBackgrounds { }; } +export function createGitColors( + config: Omit & Partial>, +): GitColors { + return { + ...config, + staged: config.staged ?? config.added, + stagedBg: config.stagedBg ?? config.addedBg, + }; +} + export function createDarkNeutralScrollbar(): ScrollbarColors { return { thumb: 'rgba(255, 255, 255, 0.15)', diff --git a/src/web-ui/src/infrastructure/theme/presets/slate-theme.ts b/src/web-ui/src/infrastructure/theme/presets/slate-theme.ts index bdde4dfd0..da64a18cb 100644 --- a/src/web-ui/src/infrastructure/theme/presets/slate-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/slate-theme.ts @@ -5,6 +5,7 @@ import { createDarkNeutralBorder, createDarkNeutralElement, createDarkNeutralScrollbar, + createGitColors, createSlateRadius, createStandardEasing, createStandardSpacing, @@ -97,7 +98,7 @@ export const bitfunSlateTheme: ThemeConfig = { element: createDarkNeutralElement(), - git: { + git: createGitColors({ branch: '#9ca6b8', branchBg: 'rgba(255, 255, 255, 0.06)', changes: 'rgb(245, 158, 11)', @@ -106,9 +107,7 @@ export const bitfunSlateTheme: ThemeConfig = { addedBg: 'rgba(127, 184, 153, 0.1)', deleted: 'rgb(201, 135, 141)', deletedBg: 'rgba(201, 135, 141, 0.1)', - staged: 'rgb(127, 184, 153)', - stagedBg: 'rgba(127, 184, 153, 0.1)', - }, + }), scrollbar: createDarkNeutralScrollbar(), }, diff --git a/src/web-ui/src/infrastructure/theme/presets/themePresetOutput.test.ts b/src/web-ui/src/infrastructure/theme/presets/themePresetOutput.test.ts index d3b34546a..fbfca738b 100644 --- a/src/web-ui/src/infrastructure/theme/presets/themePresetOutput.test.ts +++ b/src/web-ui/src/infrastructure/theme/presets/themePresetOutput.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { builtinThemes } from './index'; +import { createGitColors } from './shared'; function hashTheme(theme: unknown): string { return createHash('sha256') @@ -10,6 +11,38 @@ function hashTheme(theme: unknown): string { } describe('builtin theme preset output', () => { + it('aliases staged git colors to added colors unless a theme overrides them', () => { + expect(createGitColors({ + branch: '#64748b', + branchBg: 'rgba(100, 116, 139, 0.1)', + changes: '#f59e0b', + changesBg: 'rgba(245, 158, 11, 0.1)', + added: '#22c55e', + addedBg: 'rgba(34, 197, 94, 0.1)', + deleted: '#ef4444', + deletedBg: 'rgba(239, 68, 68, 0.1)', + })).toMatchObject({ + staged: '#22c55e', + stagedBg: 'rgba(34, 197, 94, 0.1)', + }); + + expect(createGitColors({ + branch: '#64748b', + branchBg: 'rgba(100, 116, 139, 0.1)', + changes: '#f59e0b', + changesBg: 'rgba(245, 158, 11, 0.1)', + added: '#22c55e', + addedBg: 'rgba(34, 197, 94, 0.1)', + deleted: '#ef4444', + deletedBg: 'rgba(239, 68, 68, 0.1)', + staged: '#10b981', + stagedBg: 'rgba(16, 185, 129, 0.1)', + })).toMatchObject({ + staged: '#10b981', + stagedBg: 'rgba(16, 185, 129, 0.1)', + }); + }); + it('keeps resolved preset objects stable across helper refactors', () => { expect(builtinThemes.map(theme => ({ id: theme.id, diff --git a/src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts b/src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts index 7eaacb76d..4f77b43f9 100644 --- a/src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts +++ b/src/web-ui/src/infrastructure/theme/presets/tokyo-night-theme.ts @@ -4,6 +4,7 @@ import { ThemeConfig } from '../types'; import { createCompactRadius, createExpressiveTypography, + createGitColors, createStandardEasing, createStandardSpacing, createWindowControls, @@ -100,7 +101,7 @@ export const bitfunTokyoNightTheme: ThemeConfig = { elevated: 'rgba(122, 162, 247, 0.22)', }, - git: { + git: createGitColors({ branch: 'rgb(122, 162, 247)', branchBg: 'rgba(122, 162, 247, 0.12)', changes: 'rgb(224, 175, 104)', @@ -111,7 +112,7 @@ export const bitfunTokyoNightTheme: ThemeConfig = { deletedBg: 'rgba(247, 118, 142, 0.12)', staged: 'rgb(158, 206, 106)', stagedBg: 'rgba(158, 206, 106, 0.12)', - }, + }), scrollbar: { thumb: 'rgba(134, 139, 196, 0.15)',