Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions docs/architecture/theme-token-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Mermaid、Monaco/editor、Prism syntax、terminal ANSI、language identity 和 U
registry。Mobile Web 和 Installer 已纳入同一审计口径,但各自使用独立 baseline,
避免移动端或安装器的独立 token 被误算为 web-ui 游离 key。

`346` 个 web-ui 唯一颜色是前端生产文件的全域审计数,不是普通 app UI 的色值预算。
`345` 个 web-ui 唯一颜色是前端生产文件的全域审计数,不是普通 app UI 的色值预算。
其中包含主题 preset、token contract、Mermaid、Monaco/editor、terminal、syntax、
language identity 和 UI exception 等专用 palette。language identity 已收敛为 8 个大类身份色,
不再按每种语言或文件类型保留独立色值。真正需要继续压缩的是这些专用域
Expand All @@ -174,12 +174,12 @@ label、icon、扩展名或文件名之一,不允许构建只靠颜色区分

| 指标 | 当前基线 |
| --- | ---: |
| 扫描的生产前端文件数 | 1542 |
| 扫描的生产前端文件数 | 1543 |
| 忽略的测试文件数 | 224 |
| 忽略的构建生成文件数 | 1 |
| 忽略的构建生成文件数 | 0 |
| 包含颜色字面量的文件数 | 25 |
| 颜色字面量出现次数 | 538 |
| 唯一颜色字面量数量 | 346 |
| 颜色字面量出现次数 | 536 |
| 唯一颜色字面量数量 | 345 |
| 组件或非 token 文件中的颜色出现次数 | 0 |
| 组件或非 token 唯一颜色数量 | 0 |
| App UI 颜色出现次数 | 0 |
Expand All @@ -197,7 +197,7 @@ label、icon、扩展名或文件名之一,不允许构建只靠颜色区分

| root | 颜色出现次数 | 唯一颜色 | App UI raw | fallback var | unresolved / non-contract key | dynamic family | 说明 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| `src/web-ui/src` | 538 | 346 | 0 | 0 | 0 | 13 | 主应用完整主题、专用 palette、widget payload 和 editor/terminal/Mermaid 域;UI exception、syntax、language identity 与 boundary fallback 已收敛到小型语义 palette,未读取的 git 同义 runtime/static key 已退役。 |
| `src/web-ui/src` | 536 | 345 | 0 | 0 | 0 | 13 | 主应用完整主题、专用 palette、widget payload 和 editor/terminal/Mermaid 域;UI exception、syntax、language identity 与 boundary fallback 已收敛到小型语义 palette,未读取的 git 同义 runtime/static key 和未消费 legacy mixin 已退役。 |
| `src/mobile-web/src` | 35 | 30 | 0 | 0 | 0 | 3 | mobile-web 图片缩略图关闭按钮已读取 `--color-static-white`,普通 app UI raw 归零。 |
| `BitFun-Installer/src` | 76 | 62 | 0 | 0 | 0 | 1 | 安装器主题数据保留主题卡可见的 primary/secondary background、accent、text 和状态反馈;runtime 只导出实际消费的 accent family、基础 text/border/element/status key,不复制主应用 purple/info/tooltip 等完整主题面。 |

Expand Down Expand Up @@ -277,7 +277,7 @@ PR 通过而放宽 `appUi`、fallback、unresolved、non-contract 或 dynamic fa
| 区域 | 当前出现次数 | 当前唯一色数 | 说明 |
| --- | ---: | ---: | --- |
| Theme presets | 171 | 119 | 主题个性与 palette 映射;跨主题深色 neutral、弱文本、非状态浅色背景和同概念 success 色已收敛;相邻 surface、主题识别主背景、状态色和 editor lineHighlight 继续保留 |
| Token contracts | 110 | 98 | `tokens.scss` 等静态契约根;黑白 overlay alpha stop 继续保留相邻状态层级,不按数值相近强行合并 |
| Token contracts | 108 | 97 | `tokens.scss` 等静态契约根;黑白 overlay alpha stop 继续保留相邻状态层级,未消费 legacy mixin 和自引用别名已移除,不按数值相近强行合并 |
| Editor | 52 | 48 | Monaco/editor 专用域,不能直接泛化到 app token;被动 selection/word highlight 已收敛,但 diff text/line/gutter 继续保留用户可见层级 |
| Mermaid | 82 | 65 | Mermaid 专用渲染域;light status fallback 已改读 app semantic status token,critical/error 背景弱重复已收敛,note 文本保留专用高对比值 |
| Theme runtime | 27 | 26 | `ThemeService.ts` 运行时注入;黑白 overlay alpha 与静态 token、payload shell 保持相同 stop,避免 early render 与 runtime 状态层级漂移 |
Expand All @@ -291,7 +291,8 @@ PR 通过而放宽 `appUi`、fallback、unresolved、non-contract 或 dynamic fa
| Syntax | 16 | 16 | Prism syntax palette,已按 foreground、muted/comment、keyword、literal、function、markup 等角色收敛;light punctuation 与 dark tag/property 保留相邻代码可读性差异 |

专用域 near color pair 已单独进入证据队列,避免把 editor、terminal、Mermaid、
theme preset 或 boundary fallback 误算成普通 app UI 债务。当前队列不是自动合并指令,
theme preset 或 boundary fallback 误算成普通 app UI 债务。下表是 `src/web-ui/src`
root 的当前队列,不是自动合并指令,
而是后续截图和语义复核的候选清单:

| 专用域 | 肉眼不可区分 pair | 需证据复核 pair | 后续处理原则 |
Expand All @@ -304,13 +305,18 @@ theme preset 或 boundary fallback 误算成普通 app UI 债务。当前队列
| Editor | 0 | 0 | Monaco selection、diff、inline highlight 和 light/dark editor 近似队列已清零 |
| Syntax / Terminal / Generated widget / Debug overlay / UI exception / Language identity / Visual effects | 0 | 0 | 当前无 near 队列;新增会被单域 baseline 拦截 |

专用域 near pair 不是隐式豁免。当前保留项必须在
`scripts/theme-color-near-pair-decisions.json` 中有 root、owner、reason 和 reevaluateWhen;
审计测试会阻止 web-ui、mobile-web 或 installer 新增 near pair 没有决策,
也会阻止已合并 pair 的过期决策继续留在 registry 中。

剩余高频文件均为专用 palette 或集中 registry:

| 文件 | 颜色出现次数 | 后续处理策略 |
| --- | ---: | --- |
| `src/web-ui/src/component-library/styles/tokens.scss` | 97 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 |
| `src/web-ui/src/component-library/styles/tokens.scss` | 95 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 |
| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 62 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 |
| `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 41 | Monaco theme palette;不拆散到普通 app token |
| `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 46 | Monaco theme palette;不拆散到普通 app token |
| `src/web-ui/src/infrastructure/theme/core/ThemeService.ts` | 27 | 运行时注入;需保持 early render、system theme 和 payload 导出兼容 |
| `src/web-ui/src/tools/terminal/utils/xtermTheme.ts` | 36 | terminal ANSI palette;不与 app semantic color 合并 |
| `src/web-ui/src/tools/mermaid-editor/theme/_tokens.scss` | 20 | Mermaid SCSS token 兜底;只在 Mermaid owner 内治理 |
Expand Down Expand Up @@ -1094,12 +1100,11 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值
- 高风险 surface 是否有截图或 focused visual check。
- PR 描述是否说明了任何用户可见视觉变化。

## 后续收敛顺序
## 持续收敛约束

后续不再按历史阶段拆零碎 PR,而是围绕能继续降低色值数量和降低扩展歧义的
大块工作推进:
以下内容不是日期或进度记录,而是后续每轮主题变更都必须保持的约束:

1. 中心 token 与 mixin 层压缩:普通消费侧应保持不直接读取颜色类 Sass token;后续压缩空间集中在
1. 中心 token 与 mixin 层压缩:普通消费侧应保持不直接读取颜色类 Sass token;压缩空间集中在
`tokens.scss` 的静态定义、legacy mixin、未消费 root export、badge/glass/shadow 派生和局部变量别名。
只有被 `var()`、runtime 注入、payload 或明确边界消费的 key 才应进入 root/runtime contract。
2. 插件/主题扩展投影治理:插件侧只消费 `pluginThemeProjection.ts` 暴露的 7 个 OpenCode-compatible 语义色 key;
Expand Down
20 changes: 20 additions & 0 deletions scripts/audit-cli-theme-colors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import {
checkBaseline,
collectPresetColorEntriesFromJson,
collectRustFallbackEntriesFromText,
createCliThemeColorReport,
findNearPairs,
isRuntimePresetEntry,
normalizeHexColor,
writeReportJson,
} from './audit-cli-theme-colors.mjs';

const root = process.cwd();

test('normalizeHexColor accepts supported CLI hex colors only', () => {
assert.equal(normalizeHexColor('#AABBCC'), '#aabbcc');
assert.equal(normalizeHexColor('#abc'), null);
Expand Down Expand Up @@ -85,6 +88,23 @@ test('CLI runtime key audit stays aligned with the Rust theme resolver', () => {
assert.deepEqual(Array.from(CLI_RUNTIME_THEME_KEYS).sort(), rustRuntimeKeys);
});

test('CLI OpenCode presets keep compatibility declarations outside the runtime projection', () => {
const report = createCliThemeColorReport();
const presetDir = path.join(root, 'src/apps/cli/themes/presets');
const presetFiles = fs.readdirSync(presetDir).filter(file => file.endsWith('.json')).sort();

assert.ok(report.compatibilityPresetColorOccurrences > report.runtimePresetColorOccurrences);
assert.ok(report.runtimePresetColorOccurrences < report.presetColorOccurrences);
assert.deepEqual(report.runtimeThemeKeys, Array.from(CLI_RUNTIME_THEME_KEYS).sort());
assert.equal(report.runtimePresetNearPairs.nearTotal, 0);
assert.equal(report.compatibilityPresetNearPairs.nearTotal, 0);

for (const file of presetFiles) {
const json = JSON.parse(fs.readFileSync(path.join(presetDir, file), 'utf8'));
assert.equal(json.$schema, 'https://opencode.ai/theme.json', `${file} must remain OpenCode-compatible`);
}
});

test('collectRustFallbackEntriesFromText reads Theme struct RGB fields only', () => {
const entries = collectRustFallbackEntriesFromText('theme.rs', `
primary: Color::Rgb(59, 130, 246),
Expand Down
96 changes: 96 additions & 0 deletions scripts/audit-theme-colors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const SOURCE_OWNER_ROOTS = [
'src/mobile-web/src',
'src/web-ui/src',
];
const NEAR_PAIR_DECISION_AUDIT_ROOTS = [
{ root: 'src/web-ui/src', args: ['--json', '--no-baseline', '--top', '0'] },
{ root: 'src/mobile-web/src', args: ['--root', 'src/mobile-web/src', '--json', '--no-baseline', '--top', '0'] },
{ root: 'BitFun-Installer/src', args: ['--root', 'BitFun-Installer/src', '--json', '--no-baseline', '--top', '0'] },
];

function contractOwnerHasKnownSource(owner) {
return String(owner ?? '')
Expand Down Expand Up @@ -59,6 +64,31 @@ function readText(filePath) {
return fs.readFileSync(filePath, 'utf8');
}

function collectRepositoryNearPairRows(sourceRoot, report) {
return COLOR_DOMAIN_KEYS.flatMap((domain) => {
const pairs = report.colorDomainNearPairs?.[domain];
if (!pairs) {
return [];
}
return [
...(pairs.indistinguishable ?? []),
...(pairs.near ?? []),
].map(pair => ({
root: sourceRoot,
domain,
key: pair.key,
}));
}).sort((left, right) => (
left.root.localeCompare(right.root)
|| left.domain.localeCompare(right.domain)
|| left.key.localeCompare(right.key)
));
}

function formatNearPairDecisionKey(row) {
return `${row.root}:${row.domain}:${row.key}`;
}

function createFixture(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-theme-audit-'));
const sourceRoot = path.join(dir, 'src', 'web-ui', 'src');
Expand Down Expand Up @@ -201,6 +231,72 @@ test('repository dynamic CSS var families match the registered contract', () =>
}
});

test('plugin theme projection stays compact and isolated from runtime/widget contracts', () => {
const projectionSource = readText(path.join(root, 'src/web-ui/src/infrastructure/theme/pluginThemeProjection.ts'));
const publicIndexSource = readText(path.join(root, 'src/web-ui/src/infrastructure/theme/index.ts'));
const keyListMatch = projectionSource.match(/PLUGIN_THEME_COLOR_KEYS\s*=\s*\[([\s\S]*?)\]\s+as const;/);
assert.ok(keyListMatch, 'plugin projection must declare PLUGIN_THEME_COLOR_KEYS');
const keyMatches = Array.from(
keyListMatch[1].matchAll(/'([a-z]+)'/g),
match => match[1],
);

assert.deepEqual(keyMatches, [
'primary',
'secondary',
'accent',
'success',
'warning',
'error',
'info',
]);
assert.equal(keyMatches.length, 7, 'plugin projection must stay within the documented key cap');
assert.doesNotMatch(
projectionSource,
/ThemeService|themePayload|WIDGET_THEME|getComputedStyle|setProperty|--[a-z0-9-]+/i,
'plugin projection must not become a runtime CSS var or generated widget schema',
);
assert.match(publicIndexSource, /createPluginThemeColorProjection/);
assert.doesNotMatch(
publicIndexSource,
/themePayload|WIDGET_THEME/i,
'theme public index must not expose generated widget payload as plugin theme API',
);
});

test('repository specialized near color pairs have explicit decisions', () => {
const reportedRows = NEAR_PAIR_DECISION_AUDIT_ROOTS.flatMap(({ root: sourceRoot, args }) => {
const result = runAudit(args);
assert.equal(result.status, 0, result.stderr || result.stdout);

return collectRepositoryNearPairRows(sourceRoot, JSON.parse(result.stdout));
});
const decisions = readJson(path.join(root, 'scripts/theme-color-near-pair-decisions.json'));
assert.equal(decisions.version, 1);
assert.ok(Array.isArray(decisions.decisions));

const reportedKeys = new Set(reportedRows.map(formatNearPairDecisionKey));
const decisionKeys = new Set(decisions.decisions.map(formatNearPairDecisionKey));
assert.equal(decisionKeys.size, decisions.decisions.length, 'near-pair decisions must be unique by root, domain, and key');
const missing = reportedRows
.filter(row => !decisionKeys.has(formatNearPairDecisionKey(row)))
.map(formatNearPairDecisionKey);
const stale = decisions.decisions
.filter(row => !reportedKeys.has(formatNearPairDecisionKey(row)))
.map(formatNearPairDecisionKey);

assert.deepEqual(missing, [], 'new specialized near pairs require an explicit merge/keep/defer decision');
assert.deepEqual(stale, [], 'retired specialized near-pair decisions must be removed with the lowered baseline');
for (const decision of decisions.decisions) {
assert.ok(SOURCE_OWNER_ROOTS.includes(decision.root), `${decision.key} must name a scanned root`);
assert.ok(['merge', 'keep', 'defer'].includes(decision.decision), `${decision.key} has an invalid decision`);
assert.ok(String(decision.owner).trim().length > 10, `${decision.key} must name an owner`);
assert.ok(fs.existsSync(path.join(root, decision.owner)), `${decision.key} owner must exist in the repository`);
assert.ok(String(decision.reason).trim().length >= 60, `${decision.key} must explain the product/design reason`);
assert.ok(String(decision.reevaluateWhen).trim().length >= 30, `${decision.key} must define reevaluation criteria`);
}
});

test('generated widget iframe compatibility aliases stay outside root/runtime contracts', () => {
const source = readText(path.join(root, 'src/web-ui/src/tools/generative-widget/themePayloadCompatibility.ts'));
const aliasEntries = Array.from(source.matchAll(/'([^']+)': '([^']+)'/g))
Expand Down
4 changes: 2 additions & 2 deletions scripts/theme-color-governance-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"max": 0
},
"colorScopes.token.uniqueColors": {
"max": 201
"max": 200
},
"colorScopes.exception.uniqueColors": {
"max": 182
Expand Down Expand Up @@ -249,7 +249,7 @@
"max": 27
},
"colorDomainScopes.tokenContract.occurrences": {
"max": 110
"max": 108
},
"colorDomainScopes.generatedWidget.occurrences": {
"max": 0
Expand Down
Loading
Loading