diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index fd74894ae8..5c0640609a 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -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 个大类身份色, 不再按每种语言或文件类型保留独立色值。真正需要继续压缩的是这些专用域 @@ -174,12 +174,12 @@ label、icon、扩展名或文件名之一,不允许构建只靠颜色区分 | 指标 | 当前基线 | | --- | ---: | -| 扫描的生产前端文件数 | 1542 | +| 扫描的生产前端文件数 | 1543 | | 忽略的测试文件数 | 224 | -| 忽略的构建生成文件数 | 1 | +| 忽略的构建生成文件数 | 0 | | 包含颜色字面量的文件数 | 25 | -| 颜色字面量出现次数 | 538 | -| 唯一颜色字面量数量 | 346 | +| 颜色字面量出现次数 | 536 | +| 唯一颜色字面量数量 | 345 | | 组件或非 token 文件中的颜色出现次数 | 0 | | 组件或非 token 唯一颜色数量 | 0 | | App UI 颜色出现次数 | 0 | @@ -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 等完整主题面。 | @@ -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 状态层级漂移 | @@ -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 | 后续处理原则 | @@ -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 内治理 | @@ -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; diff --git a/scripts/audit-cli-theme-colors.test.mjs b/scripts/audit-cli-theme-colors.test.mjs index d7c25131e0..6f50697443 100644 --- a/scripts/audit-cli-theme-colors.test.mjs +++ b/scripts/audit-cli-theme-colors.test.mjs @@ -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); @@ -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), diff --git a/scripts/audit-theme-colors.test.mjs b/scripts/audit-theme-colors.test.mjs index c18d806267..35e0f61db5 100644 --- a/scripts/audit-theme-colors.test.mjs +++ b/scripts/audit-theme-colors.test.mjs @@ -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 ?? '') @@ -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'); @@ -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)) diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index 5c42491f0b..7d54eb57bc 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -105,7 +105,7 @@ "max": 0 }, "colorScopes.token.uniqueColors": { - "max": 201 + "max": 200 }, "colorScopes.exception.uniqueColors": { "max": 182 @@ -249,7 +249,7 @@ "max": 27 }, "colorDomainScopes.tokenContract.occurrences": { - "max": 110 + "max": 108 }, "colorDomainScopes.generatedWidget.occurrences": { "max": 0 diff --git a/scripts/theme-color-near-pair-decisions.json b/scripts/theme-color-near-pair-decisions.json new file mode 100644 index 0000000000..1a1043bee6 --- /dev/null +++ b/scripts/theme-color-near-pair-decisions.json @@ -0,0 +1,150 @@ +{ + "version": 1, + "description": "Explicit decisions for specialized theme near-color pairs. Keep this file in sync with scripts/audit-theme-colors.mjs output; lower the audit baseline when a pair is merged.", + "decisions": [ + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#1a1b26 <-> #1c1c1f", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "Tokyo Night primary background uses the canonical Tokyo base hue; #1c1c1f is the default dark/china/slate neutral surface. Merging would flatten cross-theme identity rather than remove an app-level duplicate.", + "reevaluateWhen": "Only after a visual review proves Tokyo Night no longer needs a distinct primary background identity." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#1c1c1f <-> #212019", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "China Night secondary background keeps a warmer ink tint next to the shared dark neutral; it is a theme-identity surface, not a stray duplicate.", + "reevaluateWhen": "Only if China Night background surfaces are redesigned around the shared neutral scale." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#e0e3e8 <-> #e8e8e8", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "Light quaternary surface and slate button text sit near primary text/background stops; they can appear as adjacent surface/text roles and should not be collapsed without focused screenshots.", + "reevaluateWhen": "Only after button text and light surface-ramp screenshots show no loss of hierarchy." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#e8e8e8 <-> #ebe9e3", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "China Style elevated paper keeps a warm paper tint while #e8e8e8 is a shared cool neutral/text stop. The distinction is a theme personality cue.", + "reevaluateWhen": "Only if China Style paper surfaces are intentionally moved to the shared neutral ramp." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#f0f4f8 <-> #f3f3f5", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "The pair crosses light primary background and Monaco lineHighlight / button state values. Editor line highlight must remain visibly distinct from base surface roles.", + "reevaluateWhen": "Only after Monaco-focused light-theme screenshots confirm the line highlight remains readable." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#2b2d30 <-> #313335", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "Midnight background, tertiary surface, and editor lineHighlight are adjacent in the same dark theme. Merging would weaken surface and editor-row separation.", + "reevaluateWhen": "Only after a Midnight surface-ramp redesign removes the adjacent role split." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#f3f3f5 <-> #faf8f0", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "China Style paper uses a warm first-paint surface while #f3f3f5 is a cool light neutral and button state stop. This is cross-theme identity, not a duplicate role.", + "reevaluateWhen": "Only after China Style paper identity is intentionally neutralized." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#334155 <-> #363b54", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "Light strong text and Tokyo border encode different theme-local roles. They are not the same semantic token even though their RGB distance is low.", + "reevaluateWhen": "Only if both roles are remapped through a shared semantic border/text projection." + }, + { + "root": "src/web-ui/src", + "domain": "themePreset", + "key": "#ebe9e3 <-> #f0ede0", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/presets", + "reason": "China Style tertiary and elevated paper surfaces are adjacent paper-ramp stops. Collapsing them would reduce section/elevation separation.", + "reevaluateWhen": "Only after China Style paper-ramp screenshots prove the two stops are not adjacent in product UI." + }, + { + "root": "src/web-ui/src", + "domain": "themeRuntime", + "key": "rgba(255, 255, 255, 0.12) <-> rgba(255, 255, 255, 0.15)", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/core/ThemeService.ts", + "reason": "The 0.12 and 0.15 white overlay stops separate hover/active/elevated states and must stay aligned with static tokens.", + "reevaluateWhen": "Only after the runtime and static overlay ladder are simplified together with visual coverage for affected card, markdown, and shadow states." + }, + { + "root": "src/web-ui/src", + "domain": "themeRuntime", + "key": "rgba(0, 0, 0, 0.12) <-> rgba(0, 0, 0, 0.15)", + "decision": "keep", + "owner": "src/web-ui/src/infrastructure/theme/core/ThemeService.ts", + "reason": "The 0.12 and 0.15 black overlay stops separate light-theme card/elevated/active states and must stay aligned with static tokens.", + "reevaluateWhen": "Only after light-theme surface-state screenshots show no hierarchy loss." + }, + { + "root": "src/web-ui/src", + "domain": "tokenContract", + "key": "rgba(255, 255, 255, 0.12) <-> rgba(255, 255, 255, 0.15)", + "decision": "keep", + "owner": "src/web-ui/src/component-library/styles/tokens.scss", + "reason": "Static token stops mirror runtime overlay state levels for first paint. Removing one side without runtime alignment would create early-render drift.", + "reevaluateWhen": "Only when the matching runtime overlay pair is also retired." + }, + { + "root": "src/web-ui/src", + "domain": "tokenContract", + "key": "rgba(0, 0, 0, 0.12) <-> rgba(0, 0, 0, 0.15)", + "decision": "keep", + "owner": "src/web-ui/src/component-library/styles/tokens.scss", + "reason": "Static black overlay stops mirror runtime light-theme state levels for first paint. The pair is a state ramp, not an unowned duplicate.", + "reevaluateWhen": "Only when the matching runtime overlay pair is also retired." + }, + { + "root": "BitFun-Installer/src", + "domain": "themePreset", + "key": "#0e0e10 <-> #121214", + "decision": "keep", + "owner": "BitFun-Installer/src/theme/installerThemesData.ts", + "reason": "Cyber and default dark installer cards keep distinct theme preview backgrounds; the values appear as separate theme cards rather than adjacent layers inside one installer surface.", + "reevaluateWhen": "Only after installer theme preview screenshots show Cyber and default dark cards no longer need separate preview identity." + }, + { + "root": "BitFun-Installer/src", + "domain": "themePreset", + "key": "#1a1b26 <-> #1a1c1e", + "decision": "keep", + "owner": "BitFun-Installer/src/theme/installerThemesData.ts", + "reason": "Tokyo Night primary background intentionally remains close to the web Tokyo hue while the shared installer card surface stays neutral for preview contrast.", + "reevaluateWhen": "Only if the installer Tokyo Night preview is redesigned to inherit the shared dark card surface." + }, + { + "root": "BitFun-Installer/src", + "domain": "themePreset", + "key": "#121214 <-> #1a1814", + "decision": "keep", + "owner": "BitFun-Installer/src/theme/installerThemesData.ts", + "reason": "Ink Night keeps a warm dark preview background near the default dark installer card background; this communicates theme identity across separate theme cards.", + "reevaluateWhen": "Only after installer preview screenshots prove the warm Ink Night identity is no longer visually meaningful." + } + ] +} diff --git a/src/web-ui/src/component-library/components/Tabs/Tabs.scss b/src/web-ui/src/component-library/components/Tabs/Tabs.scss index 441f2a3526..8440969d1c 100644 --- a/src/web-ui/src/component-library/components/Tabs/Tabs.scss +++ b/src/web-ui/src/component-library/components/Tabs/Tabs.scss @@ -145,8 +145,18 @@ border-radius: tokens.$size-radius-sm; &--active { - @include tokens.liquid-glass-base; + background: linear-gradient(135deg, + var(--element-bg-soft) 0%, + var(--element-bg-soft) 50%, + var(--element-bg-base) 100%); + border: 1px solid var(--color-overlay-white-08); + backdrop-filter: tokens.$blur-base; + -webkit-backdrop-filter: tokens.$blur-base; + box-shadow: + 0 2px 4px var(--color-overlay-black-12), + inset 0 1px 0 var(--color-overlay-white-08); color: var(--color-text-primary); + overflow: hidden; } &:hover:not(&--disabled):not(&--active) { diff --git a/src/web-ui/src/component-library/styles/README.md b/src/web-ui/src/component-library/styles/README.md index 45ba7515e3..3962fa0998 100644 --- a/src/web-ui/src/component-library/styles/README.md +++ b/src/web-ui/src/component-library/styles/README.md @@ -32,7 +32,7 @@ This directory defines BitFun component design tokens to unify colors, typograph @import '../../styles/tokens.scss'; .card { - background: $card-bg; + background: $card-bg-default; border: 1px solid var(--border-base); box-shadow: var(--shadow-sm); } diff --git a/src/web-ui/src/component-library/styles/README.zh-CN.md b/src/web-ui/src/component-library/styles/README.zh-CN.md index c34cd6fbf0..47d67b7df7 100644 --- a/src/web-ui/src/component-library/styles/README.zh-CN.md +++ b/src/web-ui/src/component-library/styles/README.zh-CN.md @@ -32,7 +32,7 @@ @import '../../styles/tokens.scss'; .card { - background: $card-bg; + background: $card-bg-default; border: 1px solid var(--border-base); box-shadow: var(--shadow-sm); } diff --git a/src/web-ui/src/component-library/styles/tokens.scss b/src/web-ui/src/component-library/styles/tokens.scss index d3fc54aafb..7c5e91db63 100644 --- a/src/web-ui/src/component-library/styles/tokens.scss +++ b/src/web-ui/src/component-library/styles/tokens.scss @@ -172,7 +172,6 @@ $glow-shadow-mixed: $inner-glow-top: inset 0 1px 0 $overlay-white-08; $inner-glow-top-hover: inset 0 1px 0 $overlay-white-25; -$inner-glow-bottom: inset 0 -1px 0 $overlay-white-04; $inner-glow-bottom-blue: inset 0 -1px 0 $color-blue-600-a12; // ==================== Blur system ==================== @@ -324,7 +323,6 @@ $card-bg-accent: $color-blue-500-a08; $card-bg-accent-hover: $color-blue-500-a15; $card-bg-purple: $color-purple-500-a10; $card-bg-purple-hover: $color-purple-500-a15; -$card-bg: $card-bg-default; // Legacy alias // ==================== Badge/label tokens ==================== // Badges/labels are borderless by default and use background color to differentiate @@ -355,314 +353,6 @@ $badge-error-text: $color-error; $badge-info-bg: $color-info-bg; $badge-info-text: $color-info; -// ==================== Mixin system ==================== - -// Legacy: interactive base styles (prefer dashed-interactive-base) -@mixin liquid-glass-base { - background: linear-gradient(135deg, - $overlay-white-08 0%, - $overlay-white-08 50%, - $overlay-white-12 100% - ); - border: 1px solid $overlay-white-08; - color: $color-text-secondary; - backdrop-filter: $blur-base; - -webkit-backdrop-filter: $blur-base; - box-shadow: - 0 2px 4px $overlay-black-12, - $inner-glow-top; - transition: all $motion-base $easing-standard; - position: relative; - overflow: hidden; -} - -// Legacy: gradient hover styles (prefer dashed-interactive-hover) -@mixin liquid-glass-hover($color: 'blue') { - @if $color == 'blue' { - background: linear-gradient(135deg, - $color-blue-600-a15 0%, - $color-blue-500-a12 30%, - $color-blue-600-a08 60%, - $color-blue-600-a18 100% - ); - border-color: $color-blue-600-a40; - box-shadow: - $glow-shadow-blue, - $inner-glow-top-hover, - $inner-glow-bottom-blue; - } @else if $color == 'purple' { - background: linear-gradient(135deg, - $color-purple-500-a15 0%, - $color-purple-500-a15 30%, - $color-purple-500-a08 60%, - $color-purple-500-a15 100% - ); - border-color: $color-purple-500-a40; - box-shadow: - $glow-shadow-purple, - $inner-glow-top-hover, - inset 0 -1px 0 $color-purple-500-a12; - } @else if $color == 'mixed' { - background: linear-gradient(135deg, - $color-blue-600-a12 0%, - $color-purple-500-a12 100% - ); - border-color: $color-blue-600-a40; - box-shadow: - $glow-shadow-mixed, - $inner-glow-top-hover, - $inner-glow-bottom-blue; - } - - color: $color-text-primary; - backdrop-filter: $blur-strong; - -webkit-backdrop-filter: $blur-strong; - transform: translateY(-3px) scale(1.03); -} - -@mixin light-flow($color: 'white') { - &::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - z-index: 1; - pointer-events: none; - - @if $color == 'white' { - background: linear-gradient(90deg, - transparent, - $overlay-white-08, - $overlay-white-08, - $overlay-white-08, - transparent - ); - } @else if $color == 'blue' { - background: linear-gradient(90deg, - transparent, - $color-blue-500-a08, - rgba(147, 197, 253, 0.15), - $color-blue-500-a08, - transparent - ); - } @else if $color == 'purple' { - background: linear-gradient(90deg, - transparent, - $color-purple-500-a08, - $color-purple-500-a15, - $color-purple-500-a08, - transparent - ); - } - - transition: left $motion-slow $easing-standard; - } - - &:hover::before { - left: 100%; - } -} - -// Legacy: light flow effect -@mixin glow-flow-effect($color: $color-accent-500, $duration: 0.6s) { - @include light-flow('blue'); -} - -// Legacy: hover glow shadow -@mixin cool-hover-shadow($variant: 'blue') { - @if $variant == 'blue' { - box-shadow: $glow-shadow-blue, $inner-glow-top-hover; - } @else if $variant == 'purple' { - box-shadow: $glow-shadow-purple, $inner-glow-top-hover; - } @else if $variant == 'mixed' { - box-shadow: $glow-shadow-mixed, $inner-glow-top-hover; - } -} - -// Legacy: full hover effect combo -@mixin cool-hover-effect($color-variant: 'blue') { - transition: all $motion-base $easing-standard; - - &:hover:not(:disabled) { - @include liquid-glass-hover($color-variant); - } -} - -// Legacy: simplified default state -@mixin subtle-default-state { - @include liquid-glass-base; -} - -// ==================== Dashed/solid mixins ==================== - -@mixin dashed-interactive-base { - background: transparent; - border: 1px dashed $border-base; - border-radius: $size-radius-sm; - transition: all $motion-base $easing-standard; - cursor: pointer; -} - -@mixin dashed-interactive-hover { - background: $element-bg-subtle; - border-style: solid; - border-color: $border-strong; -} - -@mixin dashed-interactive-hover-accent { - background: $element-bg-subtle; - border-style: solid; - border-color: $border-strong; - - svg { - color: $color-accent-500; - } -} - -@mixin dashed-interactive-disabled { - opacity: 0.5; - cursor: default; - pointer-events: none; -} - -@mixin dashed-line-horizontal { - background: repeating-linear-gradient( - 90deg, - $border-medium 0, - $border-medium 4px, - transparent 4px, - transparent 8px - ); -} - -@mixin dashed-line-vertical { - background-image: linear-gradient( - 180deg, - $border-subtle 0%, - $border-subtle 50%, - transparent 50%, - transparent 100% - ); - background-size: 1px 8px; -} - -@mixin card-base { - background: transparent; - border: 1px solid $border-base; - border-radius: $size-radius-sm; - transition: all $motion-base $easing-standard; -} - -@mixin card-hover { - background: $element-bg-subtle; - border-color: $border-strong; -} - -@mixin card-hover-effect { - @include light-flow('white'); - - &:hover { - background: linear-gradient(135deg, - $color-blue-500-a08 0%, - $color-purple-500-a05 100% - ); - border-color: rgba(96, 165, 250, 0.3); - transform: translateY(-4px) scale(1.01); - box-shadow: - 0 12px 32px $color-blue-500-a15, - 0 6px 16px $color-purple-500-a10, - 0 2px 8px $overlay-black-12; - backdrop-filter: blur(12px) saturate(1.2); - -webkit-backdrop-filter: blur(12px) saturate(1.2); - } -} - -// ==================== Badge/label mixins ==================== - -@mixin badge-base { - display: inline-flex; - align-items: center; - justify-content: center; - padding: $badge-padding-y $badge-padding-x; - border-radius: $badge-border-radius; - font-size: var(--badge-font-size); - font-weight: $badge-font-weight; - line-height: 1; - border: none; - white-space: nowrap; -} - -@mixin badge-neutral { - @include badge-base; - background: $badge-neutral-bg; - color: $badge-neutral-text; -} - -@mixin badge-accent { - @include badge-base; - background: $badge-accent-bg; - color: $badge-accent-text; -} - -@mixin badge-purple { - @include badge-base; - background: $badge-purple-bg; - color: $badge-purple-text; -} - -@mixin badge-success { - @include badge-base; - background: $badge-success-bg; - color: $badge-success-text; -} - -@mixin badge-warning { - @include badge-base; - background: $badge-warning-bg; - color: $badge-warning-text; -} - -@mixin badge-error { - @include badge-base; - background: $badge-error-bg; - color: $badge-error-text; -} - -@mixin badge-info { - @include badge-base; - background: $badge-info-bg; - color: $badge-info-text; -} - -@mixin badge-variant($variant: 'neutral') { - @include badge-base; - - @if $variant == 'neutral' { - background: $badge-neutral-bg; - color: $badge-neutral-text; - } @else if $variant == 'accent' { - background: $badge-accent-bg; - color: $badge-accent-text; - } @else if $variant == 'purple' { - background: $badge-purple-bg; - color: $badge-purple-text; - } @else if $variant == 'success' { - background: $badge-success-bg; - color: $badge-success-text; - } @else if $variant == 'warning' { - background: $badge-warning-bg; - color: $badge-warning-text; - } @else if $variant == 'error' { - background: $badge-error-bg; - color: $badge-error-text; - } @else if $variant == 'info' { - background: $badge-info-bg; - color: $badge-info-text; - } -} - // ==================== Mixin for CSS variable export ==================== @mixin apply-design-tokens { diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts index 27f5c442ef..30e9ad49bb 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts @@ -3,6 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { configAPI } from '@/infrastructure/api'; import { bitfunDarkTheme, bitfunLightTheme } from '../presets'; +import { + PLUGIN_THEME_COLOR_KEYS, + createPluginThemeColorProjection, +} from '../pluginThemeProjection'; import { SYSTEM_THEME_ID, type ThemeConfig } from '../types'; import { ThemeService } from './ThemeService'; @@ -567,4 +571,43 @@ describe('ThemeService runtime theme tokens', () => { }), ).rejects.toThrow(/reserved for a built-in theme/); }); + + it('projects normalized custom themes through the compact plugin color boundary', async () => { + const service = new ThemeService(); + const partialCustomTheme = { + id: 'custom-plugin-projection', + name: 'Plugin Projection', + type: 'dark', + colors: { + accent: { + 500: '#14b8a6', + 600: '#0f766e', + }, + purple: { + 500: '#a855f7', + }, + semantic: { + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + info: '#38bdf8', + }, + }, + } as unknown as ThemeConfig; + + await service.registerTheme(partialCustomTheme); + + const normalized = service.getTheme('custom-plugin-projection'); + expect(normalized).toBeDefined(); + const projection = createPluginThemeColorProjection(normalized!); + + expect(Object.keys(projection).sort()).toEqual([...PLUGIN_THEME_COLOR_KEYS].sort()); + expect(projection.primary).toBe('#14b8a6'); + expect(projection.secondary).toBe('#a855f7'); + expect(projection.accent).toBe('#0f766e'); + expect(projection.success).toBe('#22c55e'); + expect(projection.warning).toBe('#f59e0b'); + expect(projection.error).toBe('#ef4444'); + expect(projection.info).toBe('#38bdf8'); + }); }); diff --git a/src/web-ui/src/tools/generative-widget/themePayload.test.ts b/src/web-ui/src/tools/generative-widget/themePayload.test.ts index bd819785f2..e890002322 100644 --- a/src/web-ui/src/tools/generative-widget/themePayload.test.ts +++ b/src/web-ui/src/tools/generative-widget/themePayload.test.ts @@ -286,10 +286,12 @@ describe('generated widget theme payload contract', () => { const { requestedNames } = readPayloadWithHostValues(); const compatibilityAliasCss = createWidgetThemeCompatibilityAliasCss(); const aliasEntries = readCompatibilityAliasEntries(compatibilityAliasCss); + const aliasNames = new Set(aliasEntries.map(([name]) => name)); expect(aliasEntries.map(([name]) => name).sort()).toEqual([...RETIRED_WIDGET_THEME_COMPAT_KEYS].sort()); for (const [key, canonical] of aliasEntries) { expect(RETIRED_WIDGET_THEME_COMPAT_KEYS).toContain(key); + expect(aliasNames.has(canonical)).toBe(false); expect( requestedNames.includes(canonical) || canonical in WIDGET_THEME_FALLBACK_VARS