diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index da3d51820d..af94688bfb 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -108,12 +108,12 @@ registry。 | 指标 | 当前基线 | | --- | ---: | -| 扫描的生产前端文件数 | 1535 | -| 忽略的测试文件数 | 221 | +| 扫描的生产前端文件数 | 1536 | +| 忽略的测试文件数 | 222 | | 忽略的构建生成文件数 | 0 | | 包含颜色字面量的文件数 | 25 | -| 颜色字面量出现次数 | 780 | -| 唯一颜色字面量数量 | 560 | +| 颜色字面量出现次数 | 778 | +| 唯一颜色字面量数量 | 557 | | 组件或非 token 文件中的颜色出现次数 | 0 | | 组件或非 token 唯一颜色数量 | 0 | | App UI 颜色出现次数 | 0 | @@ -124,8 +124,8 @@ registry。 | token-equivalent app literal 唯一颜色数量 | 0 | | 普通组件肉眼不可区分 near color pair | 0 | | 普通组件需证据复核的 near color pair | 0 | -| 专用域肉眼不可区分 near color pair | 6 | -| 专用域需证据复核的 near color pair | 377 | +| 专用域肉眼不可区分 near color pair | 3 | +| 专用域需证据复核的 near color pair | 375 | 当前审计未发现 CSS 变量契约层面的硬错误: @@ -157,10 +157,10 @@ registry。 | active surface token rename key | 0 | 防止 `--primary-color`、`--operation-color`、`--delay`、`--um-*` 等旧局部 key 回流 | | active surface token rename occurrences | 0 | 防止旧 key 在 SCSS、CSS 或 TSX inline style 中被重新定义或读取 | | surface token rename missing canonical | 0 | 防止 rename registry 指向不存在的 canonical key | -| generated widget payload key | 326 | widget iframe 对外主题变量 allowlist,作为外部边界单独预算,不计入内部 alias 读取 | -| generated widget payload compatibility alias | 64 | payload 仍暴露已登记 legacy alias,防止已生成或第三方内容读取失败 | +| generated widget payload key | 307 | widget iframe 对外主题变量 allowlist,作为外部边界单独预算,不计入内部 alias 读取 | +| generated widget payload compatibility alias | 45 | 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 external-only compatibility key | 62 | 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 | @@ -177,7 +177,7 @@ registry。 | Theme presets | 231 | 205 | 主题个性与 palette 映射,不作为普通 app literal 直接合并;git staged 仅在与 added 视觉语义相同时由 preset helper 复用 | | Token contracts | 176 | 157 | `tokens.scss` 等静态契约根 | | Editor | 56 | 53 | Monaco/editor 专用域,不能直接泛化到 app token;组件装饰色已迁出 raw literal | -| Mermaid | 105 | 95 | Mermaid 专用渲染域 | +| Mermaid | 103 | 92 | Mermaid 专用渲染域 | | Theme runtime | 45 | 45 | `ThemeService.ts` 运行时注入 | | Language identity | 52 | 50 | 语言身份色,已集中到 identity registry | | Terminal | 37 | 29 | terminal/ANSI 专用域;工具命令空状态已复用 `--tool-command-empty-rgb`,不再保留独立 raw 色 | @@ -198,7 +198,7 @@ theme preset 或 boundary fallback 误算成普通 app UI 债务。当前队列 | Theme runtime | 0 | 50 | 先与静态 token 和 payload contract 对齐,避免 early render 或 system theme 回退 | | Token contracts | 0 | 90 | 优先 alias 精确等价值;状态、层级和 alpha ramp 不按数值强合并 | | Boundary fallback | 0 | 6 | 只在 iframe/截图 first paint 验证后收缩 | -| Mermaid | 3 | 32 | 需检查节点、边、文本、错误态和 light/dark Mermaid 输出 | +| Mermaid | 0 | 30 | 需检查节点、边、文本、错误态和 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 拦截 | @@ -207,7 +207,7 @@ theme preset 或 boundary fallback 误算成普通 app UI 债务。当前队列 | 文件 | 颜色出现次数 | 后续处理策略 | | --- | ---: | --- | | `src/web-ui/src/component-library/styles/tokens.scss` | 151 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 | -| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 74 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 | +| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 72 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 | | `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/infrastructure/theme/core/ThemeService.ts` | 45 | 运行时注入;需保持 early render、system theme 和 payload 导出兼容 | @@ -253,6 +253,9 @@ Phase 5 决策记录: | `#ffdd00` -> `#ffd700` | merge | `StreamText.scss` fire yellow;editor/reference yellow | RGB distance = 6,均为亮黄强调色,调用点不相邻,不承担不同业务状态 | | `#7dd3fc` -> `#7DCFFF` | merge | `GenerativeWidgetToolCard.scss`;`bitfun-dark.theme.ts` editor link | RGB distance = 5,均为非状态 sky/cyan 强调,调用点跨 surface 且不相邻 | | `#00b4d8` -> `#00add8` | merge | `StreamText.scss` ocean mid;Go language identity | RGB distance = 7,同为 cyan/blue identity/visual-effect 色,非错误/警告/状态强度 | +| Mermaid `#dfe2e8` -> `#e0e2e8` | merge | light `nodeFillHover`;dark `nodeText` | RGB distance = 1,跨 light/dark fallback 角色,不在同一主题视口中承担相邻状态区分;合并后保留 `nodeFillHover` 和 `nodeText` 语义 key | +| Mermaid `#5a5e68` -> `#5a5e6a` | merge | dark `edgeLabelBorderHover`;dark `nodeStroke`/`edgeStroke` | RGB distance = 2,均为深色 Mermaid neutral stroke/border;edge label hover border 不表达独立状态严重程度,合并后仍通过 key 区分角色 | +| Mermaid `#6a6e78` -> `#6a6e7a` | merge | dark `textMuted`;dark `nodeStrokeHover` | RGB distance = 2,均为深色 Mermaid subdued neutral;不是 success/warning/error 或数据类别色,合并后保留 muted text 与 hover stroke 语义 | | `#141414` vs `#121214` | preserve | `LanguageRegistry.ts` reStructuredText identity;Flow Chat capture/editor fallback | RGB distance = 2.83,但 `#141414` 是已存在的 language identity,迁移到 registry 时保持原值;`#121214` 仅作为截图/边界兜底 | | `#b8c6ff` vs `#b8c4ff` | preserve | Slate theme purple alpha ramp;Slate purple solid stop | RGB distance = 2,但前者是原 alpha ramp 的 RGB channel,后者是 solid 500 stop;合并会改变半透明层级的实际输出 | | remaining near pairs | none in ordinary components | 无 | 审计口径下普通组件 near pair 已清零;后续只在专用 palette 自身重设计时处理 Monaco/terminal/Mermaid/syntax 内部近似色 | @@ -265,8 +268,8 @@ Phase 6 防回退约束: | --- | ---: | ---: | --- | | `nearPairs.indistinguishableTotal` | 0 | 0 | 阻止新增普通组件肉眼不可区分 pair 未被合并或记录 | | `nearPairs.nearTotal` | 0 | 0 | 阻止新增普通组件 near color 债务;新增必须合并、归类或记录理由 | -| `colorDomainNearPairs.indistinguishableTotal` | 6 | 6 | 控制专用域肉眼不可区分 pair 不继续增长,后续只能逐步降低或补充证据 | -| `colorDomainNearPairs.nearTotal` | 377 | 377 | 控制 theme preset/runtime/token/editor/Mermaid 等专用域 near 队列规模 | +| `colorDomainNearPairs.indistinguishableTotal` | 3 | 3 | 控制专用域肉眼不可区分 pair 不继续增长,后续只能逐步降低或补充证据 | +| `colorDomainNearPairs.nearTotal` | 375 | 375 | 控制 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 | @@ -282,10 +285,10 @@ Phase 6 防回退约束: | `surfaceTokenRenames.activeUnique` | 0 | 0 | 防止已迁移的 surface-local 旧 key 重新出现 | | `surfaceTokenRenames.activeOccurrences` | 0 | 0 | 防止旧 key 在定义和读取两侧回流 | | `surfaceTokenRenames.missingCanonicalUnique` | 0 | 0 | 防止 surface rename contract 指向不存在的 canonical key | -| `generatedWidgetPayload.varUnique` | 326 | 326 | 控制 widget 对外主题 payload allowlist 不继续膨胀 | -| `generatedWidgetPayload.compatibilityAliasUnique` | 64 | 64 | 控制 payload 中显式 legacy alias 数量,后续只能降低或经复审调整 | +| `generatedWidgetPayload.varUnique` | 307 | 307 | 控制 widget 对外主题 payload allowlist 不继续膨胀 | +| `generatedWidgetPayload.compatibilityAliasUnique` | 45 | 45 | 控制 payload 中显式 legacy alias 数量,后续只能降低或经复审调整 | | `generatedWidgetPayload.compatibilityAliasFamilyUnique` | 17 | 17 | 控制 payload 中 legacy size family 具体 key 数量 | -| `generatedWidgetPayload.externalOnlyCompatibilityUnique` | 81 | 81 | 标记 payload 中内部代码已不读取、仅因外部兼容保留的收缩候选 | +| `generatedWidgetPayload.externalOnlyCompatibilityUnique` | 62 | 62 | 标记 payload 中内部代码已不读取、仅因外部兼容保留的收缩候选 | | `generatedWidgetPayload.undefinedUnique` | 0 | 0 | 防止 payload 导出未定义主题 key | | `generatedWidgetPayload.missingCompatibilityCanonicalUnique` | 0 | 0 | 防止 payload 兼容 alias 缺失 canonical 目标 | | `generatedWidgetPayload.unexportedCompatibilityCanonicalUnique` | 0 | 0 | 防止 payload 兼容 alias 有 canonical 定义但未导出到 iframe | @@ -765,7 +768,7 @@ semantic token 描述产品级语义,应作为共享 UI 的默认使用层。 | git/diff 色被当作普通 success/error | added/deleted/changed/conflict 扫描效率下降。 | 使用专用 git/diff token,只有复核后才 alias 到 app intent。 | | 主题个性被抹平 | 用户选择主题的价值下降。 | theme preset 保留自己的 primitive/accent 映射。 | | fallback 先删、alias 后补 | embedded 或 early render surface 样式丢失。 | 先加 alias,再删除 fallback。 | -| 兼容 alias 读点清零时误删定义 | 旧主题、生成式 widget、host payload 或外部自定义内容读取旧 key 时样式丢失。 | 只迁移内部 `var()` 读取;`tokens.scss`、runtime 注入和 widget shell 继续暴露 legacy key。 | +| 兼容 alias 读点清零时误删定义 | 旧主题、生成式 widget、host payload 或外部自定义内容读取旧 key 时样式丢失。 | 只迁移内部 `var()` 读取;`tokens.scss` 和 runtime 注入继续保留兼容定义,widget payload 只暴露仍有消费风险的 legacy key。 | | 静态 token 与运行时 token 不一致 | widget、SCSS、runtime theme 注入结果不一致。 | `tokens.scss`、`ThemeService.ts`、`themePayload.ts` 同阶段对齐。 | | 动态 CSS 变量 key 被误判为未定义 | inline style 或数据驱动变量失去兜底,导致特定卡片、标签或分组颜色缺失。 | 对动态 key 建立运行时设置清单;删除 fallback 前补组件根默认值或保留边界 fallback。 | | contrast 验证不可信 | 可访问性回归可能漏掉。 | 先实现真实 contrast 检查,再声称可访问性改善。 | @@ -941,6 +944,9 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 accent scale 或组件 action token,只有 primary action 与 accent 明确分化时才重新建模。 - `--color-danger` 当前映射到 `--color-error`,但保留 destructive action 语义;只有破坏性动作 明确选择 error token 或迁入专用 action token 后才删除。 +- generated widget payload 已停止导出低风险的 `--color-accent*` legacy、`--color-primary*`、 + `--accent-primary*` 和 `--color-danger*` 旧拼写;根 token 与 runtime 注入仍保留这些兼容定义, + 避免影响 app 内部旧主题和历史 CSS。 - 尺寸 canonical family 是 `--size-radius-*` / `--size-gap-*`;`--radius-*` / `--spacing-*` 只作为 legacy source 和 generated widget payload 兼容面保留。 - 迁移期 CI 采用严格 baseline:普通 app raw color、内部 compatibility alias 读取、fallback、 diff --git a/package.json b/package.json index 365f68169b..c62235a9cc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "copy-icons": "copyfiles -f \"src/apps/desktop/icons/Logo-ICON.png\" \"src/web-ui/public/\"", "copy-assets": "pnpm run copy-monaco && pnpm run copy-icons", "generate-version": "node scripts/generate-version.cjs", - "generate-all": "pnpm run generate-version", + "generate-startup-theme-bootstrap": "node scripts/generate-startup-theme-bootstrap.mjs", + "generate-all": "pnpm run generate-version && pnpm run generate-startup-theme-bootstrap", "postinstall": "pnpm run copy-assets", "dev": "node scripts/dev.cjs web", "dev:web": "pnpm --dir src/web-ui dev", diff --git a/scripts/generate-startup-theme-bootstrap.mjs b/scripts/generate-startup-theme-bootstrap.mjs new file mode 100644 index 0000000000..986de868d9 --- /dev/null +++ b/scripts/generate-startup-theme-bootstrap.mjs @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createServer } from 'vite'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const webUiRoot = path.join(repoRoot, 'src/web-ui'); +const outputPath = path.join( + repoRoot, + 'src/apps/desktop/src/generated/startup_theme_bootstrap.json', +); +const checkOnly = process.argv.includes('--check'); + +const server = await createServer({ + root: webUiRoot, + logLevel: 'error', + appType: 'custom', + server: { middlewareMode: true }, + optimizeDeps: { + entries: [], + noDiscovery: true, + }, +}); + +try { + const [{ builtinThemes }, { createStartupThemeBootstrapManifest }] = await Promise.all([ + server.ssrLoadModule('/src/infrastructure/theme/presets/index.ts'), + server.ssrLoadModule('/src/infrastructure/theme/presets/startupThemeBootstrap.ts'), + ]); + + const manifest = createStartupThemeBootstrapManifest(builtinThemes); + const nextContent = `${JSON.stringify(manifest, null, 2)}\n`; + const currentContent = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, 'utf8') + : null; + + if (checkOnly) { + if (currentContent !== nextContent) { + console.error( + 'Startup theme bootstrap manifest is stale. Run `pnpm run generate-startup-theme-bootstrap`.', + ); + process.exitCode = 1; + } + } else { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, nextContent, 'utf8'); + console.log(`Generated ${path.relative(repoRoot, outputPath).replace(/\\/g, '/')}`); + } +} finally { + await server.close(); +} diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index a47645918b..11ef0c7364 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -54,19 +54,19 @@ "max": 0 }, "generatedWidgetPayload.varUnique": { - "max": 326 + "max": 307 }, "generatedWidgetPayload.occurrences": { - "max": 326 + "max": 307 }, "generatedWidgetPayload.undefinedUnique": { "max": 0 }, "generatedWidgetPayload.compatibilityAliasUnique": { - "max": 64 + "max": 45 }, "generatedWidgetPayload.compatibilityAliasOccurrences": { - "max": 64 + "max": 45 }, "generatedWidgetPayload.compatibilityAliasFamilyUnique": { "max": 17 @@ -75,10 +75,10 @@ "max": 17 }, "generatedWidgetPayload.externalOnlyCompatibilityUnique": { - "max": 81 + "max": 62 }, "generatedWidgetPayload.externalOnlyCompatibilityOccurrences": { - "max": 81 + "max": 62 }, "generatedWidgetPayload.missingCompatibilityCanonicalUnique": { "max": 0 @@ -108,7 +108,7 @@ "max": 340 }, "colorScopes.exception.uniqueColors": { - "max": 273 + "max": 270 }, "cssVarDefinitions.unresolvedRequiredUnique": { "max": 0 @@ -147,10 +147,10 @@ "max": 0 }, "colorDomainNearPairs.indistinguishableTotal": { - "max": 6 + "max": 3 }, "colorDomainNearPairs.nearTotal": { - "max": 377 + "max": 375 }, "colorDomainNearPairs.themePreset.indistinguishableTotal": { "max": 3 @@ -183,10 +183,10 @@ "max": 6 }, "colorDomainNearPairs.mermaid.indistinguishableTotal": { - "max": 3 + "max": 0 }, "colorDomainNearPairs.mermaid.nearTotal": { - "max": 32 + "max": 30 }, "colorDomainNearPairs.editor.indistinguishableTotal": { "max": 0 @@ -294,10 +294,10 @@ "max": 0 }, "colorDomainScopes.mermaid.occurrences": { - "max": 105 + "max": 103 }, "colorDomainScopes.mermaid.uniqueColors": { - "max": 95 + "max": 92 } } } diff --git a/src/apps/desktop/src/generated/startup_theme_bootstrap.json b/src/apps/desktop/src/generated/startup_theme_bootstrap.json new file mode 100644 index 0000000000..b982916ad7 --- /dev/null +++ b/src/apps/desktop/src/generated/startup_theme_bootstrap.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "defaultLightThemeId": "bitfun-light", + "defaultDarkThemeId": "bitfun-dark", + "themes": [ + { + "id": "bitfun-light", + "bgPrimary": "#f3f3f5", + "bgSecondary": "#ffffff", + "bgScene": "#ffffff", + "isLight": true, + "textPrimary": "#1e293b", + "textMuted": "#64748b", + "accentColor": "#64748b" + }, + { + "id": "bitfun-slate", + "bgPrimary": "#14161a", + "bgSecondary": "#22262c", + "bgScene": "#22262c", + "isLight": false, + "textPrimary": "#eef0f3", + "textMuted": "#9ea4ab", + "accentColor": "#94a3b8" + }, + { + "id": "bitfun-dark", + "bgPrimary": "#0e0e10", + "bgSecondary": "#1c1c1f", + "bgScene": "#1c1c1f", + "isLight": false, + "textPrimary": "#e8e8e8", + "textMuted": "#858585", + "accentColor": "#60a5fa" + }, + { + "id": "bitfun-midnight", + "bgPrimary": "#2b2d30", + "bgSecondary": "#1e1f22", + "bgScene": "#27292c", + "isLight": false, + "textPrimary": "#bcbec4", + "textMuted": "#6f737a", + "accentColor": "#58a6ff" + }, + { + "id": "bitfun-china-style", + "bgPrimary": "#faf8f0", + "bgSecondary": "#f5f3e8", + "bgScene": "#fdfcf6", + "isLight": true, + "textPrimary": "#1a1a1a", + "textMuted": "#6a6a6a", + "accentColor": "#2e5e8a" + }, + { + "id": "bitfun-china-night", + "bgPrimary": "#1a1814", + "bgSecondary": "#212019", + "bgScene": "#1e1c17", + "isLight": false, + "textPrimary": "#e8e6e1", + "textMuted": "#928f89", + "accentColor": "#73a5cc" + }, + { + "id": "bitfun-cyber", + "bgPrimary": "#101010", + "bgSecondary": "#151515", + "bgScene": "#141414", + "isLight": false, + "textPrimary": "#e0f2ff", + "textMuted": "#7fadcc", + "accentColor": "#00e6ff" + }, + { + "id": "bitfun-tokyo-night", + "bgPrimary": "#1a1b26", + "bgSecondary": "#16161e", + "bgScene": "#1a1b26", + "isLight": false, + "textPrimary": "#c0caf5", + "textMuted": "#787c99", + "accentColor": "#7aa2f7" + } + ] +} diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 4d36f1778f..fe959176f2 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -30,6 +30,9 @@ const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL: Duration = Duration::from_millis( static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); static AGENT_COMPANION_WINDOW_LAST_POSITION: OnceLock>>> = OnceLock::new(); +static STARTUP_THEME_BOOTSTRAP_MANIFEST: OnceLock = OnceLock::new(); + +const STARTUP_THEME_BOOTSTRAP_JSON: &str = include_str!("generated/startup_theme_bootstrap.json"); #[cfg(target_os = "windows")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -209,6 +212,44 @@ pub struct ThemeConfig { pub accent_color: String, } +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct StartupThemeBootstrapManifest { + version: u8, + default_light_theme_id: String, + default_dark_theme_id: String, + themes: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct StartupThemeBootstrapTheme { + id: String, + bg_primary: String, + bg_secondary: String, + bg_scene: String, + is_light: bool, + text_primary: String, + text_muted: String, + accent_color: String, +} + +impl StartupThemeBootstrapTheme { + fn to_theme_config(&self, selection_id: Option) -> ThemeConfig { + ThemeConfig { + id: self.id.clone(), + selection_id, + bg_primary: self.bg_primary.clone(), + bg_secondary: self.bg_secondary.clone(), + bg_scene: self.bg_scene.clone(), + is_light: self.is_light, + text_primary: self.text_primary.clone(), + text_muted: self.text_muted.clone(), + accent_color: self.accent_color.clone(), + } + } +} + #[derive(Debug, Clone)] struct StartupBootstrapConfig { theme: ThemeConfig, @@ -221,17 +262,11 @@ const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; impl Default for ThemeConfig { fn default() -> Self { - let mut theme = Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { - id: "bitfun-light".to_string(), - selection_id: None, - bg_primary: "#f3f3f5".to_string(), - bg_secondary: "#ffffff".to_string(), - bg_scene: "#ffffff".to_string(), - is_light: true, - text_primary: "#1e293b".to_string(), - text_muted: "#64748b".to_string(), - accent_color: "#64748b".to_string(), - }); + let default_light_theme_id = Self::startup_theme_bootstrap_manifest() + .default_light_theme_id + .as_str(); + let mut theme = Self::get_builtin_theme(default_light_theme_id) + .expect("startup theme bootstrap manifest must include the default light theme"); theme.selection_id = None; theme } @@ -239,97 +274,24 @@ impl Default for ThemeConfig { impl ThemeConfig { pub fn get_builtin_theme(theme_id: &str) -> Option { - match theme_id { - "bitfun-slate" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#1a1c1e".to_string(), - bg_secondary: "#1a1c1e".to_string(), - bg_scene: "#1d2023".to_string(), - is_light: false, - text_primary: "#e4e6e8".to_string(), - text_muted: "#8a8d92".to_string(), - accent_color: "#6b9bd5".to_string(), - }), - "bitfun-dark" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#121214".to_string(), - bg_secondary: "#18181a".to_string(), - bg_scene: "#16161a".to_string(), - is_light: false, - text_primary: "#e8e8e8".to_string(), - text_muted: "rgba(255, 255, 255, 0.4)".to_string(), - accent_color: "#60a5fa".to_string(), - }), - "bitfun-midnight" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#2b2d30".to_string(), - bg_secondary: "#1e1f22".to_string(), - bg_scene: "#27292c".to_string(), - is_light: false, - text_primary: "#bcbec4".to_string(), - text_muted: "rgba(255, 255, 255, 0.4)".to_string(), - accent_color: "#6c9eff".to_string(), - }), - "bitfun-cyber" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#101010".to_string(), - bg_secondary: "#151515".to_string(), - bg_scene: "#141414".to_string(), - is_light: false, - text_primary: "#e0f2ff".to_string(), - text_muted: "rgba(255, 255, 255, 0.4)".to_string(), - accent_color: "#00e6ff".to_string(), - }), - "bitfun-tokyo-night" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#1a1b26".to_string(), - bg_secondary: "#16161e".to_string(), - bg_scene: "#1a1b26".to_string(), - is_light: false, - text_primary: "#c0caf5".to_string(), - text_muted: "rgba(255, 255, 255, 0.4)".to_string(), - accent_color: "#7aa2f7".to_string(), - }), - "bitfun-china-night" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#1a1814".to_string(), - bg_secondary: "#141210".to_string(), - bg_scene: "#1e1c17".to_string(), - is_light: false, - text_primary: "#e8e6e1".to_string(), - text_muted: "rgba(255, 255, 255, 0.4)".to_string(), - accent_color: "#c4a35a".to_string(), - }), - "bitfun-light" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#f3f3f5".to_string(), - bg_secondary: "#ffffff".to_string(), - bg_scene: "#ffffff".to_string(), - is_light: true, - text_primary: "#1e293b".to_string(), - text_muted: "#64748b".to_string(), - accent_color: "#64748b".to_string(), - }), - "bitfun-china-style" => Some(Self { - id: theme_id.to_string(), - selection_id: Some(theme_id.to_string()), - bg_primary: "#faf8f0".to_string(), - bg_secondary: "#f5f3e8".to_string(), - bg_scene: "#fdfcf6".to_string(), - is_light: true, - text_primary: "#1a1a1a".to_string(), - text_muted: "rgba(0, 0, 0, 0.5)".to_string(), - accent_color: "#2e5e8a".to_string(), - }), - _ => None, - } + Self::startup_theme_bootstrap_manifest() + .themes + .iter() + .find(|theme| theme.id == theme_id) + .map(|theme| theme.to_theme_config(Some(theme_id.to_string()))) + } + + fn startup_theme_bootstrap_manifest() -> &'static StartupThemeBootstrapManifest { + STARTUP_THEME_BOOTSTRAP_MANIFEST.get_or_init(|| { + let manifest: StartupThemeBootstrapManifest = + serde_json::from_str(STARTUP_THEME_BOOTSTRAP_JSON) + .expect("startup theme bootstrap manifest must be valid JSON"); + assert_eq!( + manifest.version, 1, + "startup theme bootstrap manifest version is unsupported" + ); + manifest + }) } fn load_startup_bootstrap_config() -> StartupBootstrapConfig { @@ -417,9 +379,10 @@ impl ThemeConfig { /// `system` follows OS light/dark (aligned with web-ui `getSystemPreferredDefaultThemeId`). fn resolve_builtin_theme_id(theme_id: &str) -> &str { if theme_id == "system" { + let manifest = Self::startup_theme_bootstrap_manifest(); return match dark_light::detect() { - Mode::Dark => "bitfun-dark", - Mode::Light | Mode::Default => "bitfun-light", + Mode::Dark => manifest.default_dark_theme_id.as_str(), + Mode::Light | Mode::Default => manifest.default_light_theme_id.as_str(), }; } theme_id diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index cdd02d0673..ea6a43d65b 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -153,6 +153,8 @@ describe('startup performance contract', () => { expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_ID__'); expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_SELECTION__'); + expect(desktopThemeSource).toContain('include_str!("generated/startup_theme_bootstrap.json")'); + expect(desktopThemeSource).not.toContain('"bitfun-slate" => Some(Self'); expect(mainSource).toContain("before_render_step', 'theme_service_initialize'"); expect(themeServiceSource).toContain('getBootstrapThemeSelection'); expect(themeServiceSource).toContain('applyThemeSelection(bootstrapSelection, { persist: false })'); diff --git a/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.test.ts b/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.test.ts new file mode 100644 index 0000000000..bebba6fd92 --- /dev/null +++ b/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.test.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { builtinThemes, DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from './index'; +import { createStartupThemeBootstrapManifest } from './startupThemeBootstrap'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const generatedManifestPath = path.resolve( + __dirname, + '../../../../../apps/desktop/src/generated/startup_theme_bootstrap.json', +); + +describe('startup theme bootstrap manifest', () => { + it('projects only the desktop first-paint theme fields from TS builtin themes', () => { + const manifest = createStartupThemeBootstrapManifest(builtinThemes); + + expect(manifest).toMatchObject({ + version: 1, + defaultLightThemeId: DEFAULT_LIGHT_THEME_ID, + defaultDarkThemeId: DEFAULT_DARK_THEME_ID, + }); + expect(manifest.themes).toHaveLength(builtinThemes.length); + expect(new Set(manifest.themes.map(theme => theme.id)).size).toBe(builtinThemes.length); + expect(manifest.themes.map(theme => theme.id)).toEqual( + expect.arrayContaining([DEFAULT_LIGHT_THEME_ID, DEFAULT_DARK_THEME_ID]) + ); + + const light = manifest.themes.find(theme => theme.id === 'bitfun-light'); + const sourceLight = builtinThemes.find(theme => theme.id === 'bitfun-light'); + + expect(light).toEqual({ + id: sourceLight?.id, + bgPrimary: sourceLight?.colors.background.primary, + bgSecondary: sourceLight?.colors.background.secondary, + bgScene: sourceLight?.colors.background.scene, + isLight: true, + textPrimary: sourceLight?.colors.text.primary, + textMuted: sourceLight?.colors.text.muted, + accentColor: sourceLight?.colors.accent[500], + }); + }); + + it('keeps the committed desktop manifest synchronized with TS builtin themes', () => { + const generated = JSON.parse(fs.readFileSync(generatedManifestPath, 'utf8')); + + expect(generated).toEqual(createStartupThemeBootstrapManifest(builtinThemes)); + }); +}); diff --git a/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.ts b/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.ts new file mode 100644 index 0000000000..8713fccf0e --- /dev/null +++ b/src/web-ui/src/infrastructure/theme/presets/startupThemeBootstrap.ts @@ -0,0 +1,46 @@ +import type { ThemeConfig, ThemeId } from '../types'; +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from './index'; + +export const STARTUP_THEME_BOOTSTRAP_VERSION = 1; + +export interface StartupThemeBootstrapEntry { + id: ThemeId; + bgPrimary: string; + bgSecondary: string; + bgScene: string; + isLight: boolean; + textPrimary: string; + textMuted: string; + accentColor: string; +} + +export interface StartupThemeBootstrapManifest { + version: typeof STARTUP_THEME_BOOTSTRAP_VERSION; + defaultLightThemeId: ThemeId; + defaultDarkThemeId: ThemeId; + themes: StartupThemeBootstrapEntry[]; +} + +export function createStartupThemeBootstrapEntry(theme: ThemeConfig): StartupThemeBootstrapEntry { + return { + id: theme.id, + bgPrimary: theme.colors.background.primary, + bgSecondary: theme.colors.background.secondary, + bgScene: theme.colors.background.scene, + isLight: theme.type === 'light', + textPrimary: theme.colors.text.primary, + textMuted: theme.colors.text.muted, + accentColor: theme.colors.accent[500], + }; +} + +export function createStartupThemeBootstrapManifest( + themes: readonly ThemeConfig[], +): StartupThemeBootstrapManifest { + return { + version: STARTUP_THEME_BOOTSTRAP_VERSION, + defaultLightThemeId: DEFAULT_LIGHT_THEME_ID, + defaultDarkThemeId: DEFAULT_DARK_THEME_ID, + themes: themes.map(createStartupThemeBootstrapEntry), + }; +} 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 92b0b0128b..600bcef9a8 100644 --- a/src/web-ui/src/tools/generative-widget/themePayload.test.ts +++ b/src/web-ui/src/tools/generative-widget/themePayload.test.ts @@ -7,7 +7,28 @@ import { readWidgetThemePayload, } from './themePayload'; -const WIDGET_THEME_VAR_NAMES_HASH = '703944f63b95646520792c18ee2978a5d10553145a62ee53980aea2ca98baa0b'; +const WIDGET_THEME_VAR_NAMES_HASH = 'c674aa29ababf56a37ef1d9c7ccf04de06ccddc4ba7599337e079b3f8a42b3e8'; +const RETIRED_WIDGET_THEME_COMPAT_KEYS = [ + '--color-accent', + '--color-accent-primary', + '--color-accent-alpha', + '--color-primary', + '--color-primary-rgb', + '--color-primary-400', + '--color-primary-hover', + '--color-primary-500', + '--color-primary-alpha', + '--color-primary-bg', + '--color-primary-bg-subtle', + '--accent-primary', + '--accent-primary-hover', + '--color-danger', + '--color-danger-500', + '--color-danger-text', + '--color-danger-bg', + '--color-danger-border', + '--color-danger-hover', +] as const; function readPayloadWithHostValues(hostValues: Record = {}) { const requestedNames: string[] = []; @@ -60,7 +81,7 @@ describe('generated widget theme payload contract', () => { first: requestedNames[0], last: requestedNames[requestedNames.length - 1], }).toEqual({ - count: 326, + count: 307, hash: WIDGET_THEME_VAR_NAMES_HASH, first: '--color-bg-primary', last: '--tool-card-action-font-weight', @@ -73,6 +94,25 @@ describe('generated widget theme payload contract', () => { expect(payload?.vars).toEqual(WIDGET_THEME_FALLBACK_VARS); }); + it('does not export retired accent and danger compatibility keys', () => { + const { requestedNames } = readPayloadWithHostValues(); + + expect(requestedNames).not.toEqual(expect.arrayContaining(RETIRED_WIDGET_THEME_COMPAT_KEYS)); + expect(requestedNames).toEqual( + expect.arrayContaining([ + '--color-accent-50', + '--color-accent-100', + '--color-accent-400', + '--color-accent-500', + '--color-accent-500-rgb', + '--color-accent-600', + '--color-error', + '--color-error-bg', + '--color-error-border', + ]) + ); + }); + it('renders fallback CSS from the same reviewed fallback map', () => { const css = createWidgetThemeFallbackCss(); diff --git a/src/web-ui/src/tools/generative-widget/themePayload.ts b/src/web-ui/src/tools/generative-widget/themePayload.ts index a6c3b4c21f..efacf43ced 100644 --- a/src/web-ui/src/tools/generative-widget/themePayload.ts +++ b/src/web-ui/src/tools/generative-widget/themePayload.ts @@ -142,19 +142,6 @@ const WIDGET_THEME_VAR_GROUPS = { FALLBACK_VAR.accent600, '--color-accent-700', '--color-accent-800', - '--color-accent', - '--color-accent-primary', - '--color-accent-alpha', - '--color-primary', - '--color-primary-rgb', - '--color-primary-400', - '--color-primary-hover', - '--color-primary-500', - '--color-primary-alpha', - '--color-primary-bg', - '--color-primary-bg-subtle', - '--accent-primary', - '--accent-primary-hover', ], semantic: [ FALLBACK_VAR.success, @@ -172,12 +159,6 @@ const WIDGET_THEME_VAR_GROUPS = { '--color-error-bg', '--color-error-border', '--color-semantic-error', - '--color-danger', - '--color-danger-500', - '--color-danger-text', - '--color-danger-bg', - '--color-danger-border', - '--color-danger-hover', '--color-info', '--color-info-bg', '--color-info-border', diff --git a/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.test.ts b/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.test.ts index f5e701ae22..d2273ca3aa 100644 --- a/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.test.ts +++ b/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.test.ts @@ -33,6 +33,16 @@ describe('mermaid theme fallback palette', () => { }); }); + it('merges indistinguishable Mermaid fallback colors without removing semantic roles', () => { + expect(getMermaidThemeFallback('light', 'nodeFillHover')).toBe('#e0e2e8'); + expect(getMermaidThemeFallback('dark', 'edgeLabelBorderHover')).toBe( + getMermaidThemeFallback('dark', 'nodeStroke') + ); + expect(getMermaidThemeFallback('dark', 'textMuted')).toBe( + getMermaidThemeFallback('dark', 'nodeStrokeHover') + ); + }); + it('keeps every theme mode on the same fallback key contract', () => { const fallbackKeys = Object.keys(MERMAID_THEME_FALLBACKS.dark).sort(); diff --git a/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts b/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts index 05dfe2ddd6..d2c83a2ac0 100644 --- a/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts +++ b/src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts @@ -8,12 +8,13 @@ const DARK_NODE_TEXT = '#e0e2e8'; const DARK_NODE_STROKE = '#5a5e6a'; const DARK_NODE_BORDER = '#4a4e58'; const DARK_NODE_STROKE_SUBTLE = '#3a3e48'; +const DARK_NODE_STROKE_HOVER = '#6a6e7a'; const DARK_CLUSTER_TEXT = '#9a9ea8'; const DARK_INFO = '#78a8d8'; const DARK_ERROR = '#e87878'; const LIGHT_NODE_FILL = '#e8eaef'; -const LIGHT_NODE_FILL_HOVER = '#dfe2e8'; +const LIGHT_NODE_FILL_HOVER = '#e0e2e8'; const LIGHT_NODE_STROKE = '#9ca3af'; const LIGHT_NODE_STROKE_MUTED = '#d1d5db'; const LIGHT_NODE_STROKE_HOVER = '#6b7280'; @@ -37,15 +38,15 @@ export const MERMAID_THEME_FALLBACKS = { nodeBorder: DARK_NODE_BORDER, nodeStrokeMuted: DARK_NODE_BORDER, nodeStrokeSubtle: DARK_NODE_STROKE_SUBTLE, - nodeStrokeHover: '#6a6e7a', + nodeStrokeHover: DARK_NODE_STROKE_HOVER, nodeStrokeHoverStrong: '#8a8e9a', edgeStroke: DARK_NODE_STROKE, - edgeLabelBorderHover: '#5a5e68', + edgeLabelBorderHover: DARK_NODE_STROKE, clusterFill: '#16181c', clusterFillRuntime: 'rgba(24, 26, 30, 0.6)', clusterFillHover: 'rgba(34, 36, 42, 0.7)', clusterText: DARK_CLUSTER_TEXT, - textMuted: '#6a6e78', + textMuted: DARK_NODE_STROKE_HOVER, arrow: '#7a7e8a', edgeLabelBg: '#1a1c20', edgeLabelBgStrong: DARK_NODE_FILL_HOVER,