diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 2fbd289..1acb0f4 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -338,12 +338,37 @@ ### 11. 外观与浅色/深色主题 -- [ ] 对齐原版外观页面及主题切换交互;当前只保留浅色、深色两种选择。 -- [ ] 梳理原版主题切换会改变的 token、样式、资源和组件状态。 -- [ ] 统一主窗口、设置窗口、独立任务窗口、弹层、输入区、对话、代码/表格/工具结果和右侧栏的主题响应。 -- [ ] 验收:即时切换、跨窗口同步、重启持久化;所有已接页面无浅深混色和不可读内容。 +- [x] 对齐原版外观页面及主题切换交互;当前只保留浅色、深色两种选择。 +- [x] 梳理原版主题切换会改变的 token、样式、资源和组件状态。 +- [x] 统一设置窗口与主窗口已接表面的主题响应(采样验收);独立任务窗口、弹层、代码/表格/工具结果等表面尚未逐一采样(见遗留)。 +- [x] 验收:即时切换、跨窗口同步、重启持久化;采样表面无浅深混色和不可读内容。 -状态:待 REA 对照与主题覆盖审计。 +状态:已实现并通过真实窗口回归(21 项断言)。 + +原版证据: +- REA `ev_dee8f184f6d8de34338f454e3a2588c26e2621dbc29c07c65337c475a243a6a0`(WorkBuddy 5.8.3,设置窗口 target `6D5A7FEE…`,900×680):外观页最终浅色态(含 PNG 帧)。 +- REA `ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99`:深色态版式 + 模块样式表 `appearance-DMmhwsxq.css`(sha256 `af58cb3134d3e6c474424f6d718daa8f5be0c6e29d40aa5f9f80c14b3ffbbbd2`,外观页懒加载资源)。 +- REA `ev_ff4f073ea843a5f444244393b3e190ccb1803bfec2b32afbfb4edfed9cc7bbc7` / `ev_fa67fddbb3dc00dde1609ef8e72d448e586066b74949f97634e1dbd0921599dc`:切换后的稳定深色/浅色等待探针。 +- 预览图资产(REA `media_selectors` 导出,原版 `IMG.appearance-preview__image`,alt「效果预览」):浅色 `ev_c0b1dacb5fa65c88f32c2f600f0a7a6731b05e58f88b74677924a53899b17ff7`(1260×840,sha256 `0b1f24f382c53fa6d3b364bf89d0bbe62b4c6c403dc5a6263c9c5b7fcec7f925`)、深色 `ev_43c82492b3154ca67bcccc0bfb54694d1e4d06e0baf72560e49355747b1ad3f7`(sha256 `ca133c7200b03a49170658e9661f4b64d8ecdf30f8c6395b7f07dc25eff653b6`);图片本身含原版 V5.0.2 样例角标,属原版静态资源内容。 +- 原版几何:`.appearance-panel` [232,40,638,640]、`.appearance-preview` [236,40,634,317]、预览图 [308.906,86.281,488.172,270.719](radius 8px 8px 0 0)、徽标「永久有效」[806,48,56,20]、标题「全部主题」[236,381,56,20]、网格 [236,413,634,218.391](gap 12px 8px、padding 4px 4px 0)、卡片 118.797×79.188、名称 118.797×18;原版共 9 张卡(浅色/深色/有风/经典QQ/和平精英激战金秋/同行/涟漪/伙伴/敬请期待),面板内无字号/主题色/动效控件。 +- token 结论:主题切换是纯颜色层——浅色域可解析变量 1870 个,其中 562 个在深色域取值不同,尺寸/字体/间距类差异 0(本轮复核 tokens 表 sha256 `13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696`)。 +- 观察范围:桌面端 900×680 设置窗口的外观页 + 主窗口 1336×1240 抽查;未覆盖:复合主题切换的单次连续采集(REA 返回 `target_changed`,改以稳定深/浅两态等待探针取证)、系统跟随模式、原版其余 7 张主题卡的行为。 + +实现: +- `scripts/rea/recover-appearance-panel.mjs`:从 `ev_24ab5853…` 的模块样式表投影 44 条 `.appearance-*` 规则,把原版深色选择器(`.dark`/`[data-cb-theme=dark]`)改写成 YCode 的 `[data-theme=dark]`,并按引用闭包复制作用域 token,生成 `src/renderer/src/components/source-appearance-panel-recovered.css`;`source-appearance-panel.css` 只做 import 与本地适配(标题行盒 20px)。 +- `src/renderer/src/components/AppearanceSettingsPanel.tsx`:预览图 + 「永久有效」徽标 + 「全部主题」+ 浅色/深色两张卡(`aria-pressed`、`appearance-card--selected` 选中态);`appearance-assets/provenance.json` 记录两张预览图的证据与 sha256。 +- `src/renderer/src/components/SettingsDialog.tsx`:外观页接入 `AppearanceSettingsPanel`(替换旧的 `.segmented` 占位控件)。 +- `scripts/rea/recover-settings-shell.mjs`:为 `.settings-window-root,.settings-modal` 追加作用域 token(67 light/37 dark)并重生成 `settings-shell-source.css`,修复深色下设置窗口仍白底、近白文字的真实缺陷。 + +验证:`scripts/verify-appearance-settings.cjs`(真实隔离 Electron + 原生设置窗口,视口 900×680)21 项断言全部通过:导航/面板/预览卡/徽标/标题/网格/卡片/名称与被回收原版几何逐一比对;浅色选中环 solid 2px;点击「深色」即时切换(`data-theme`、`localStorage['ycode:theme']`、预览图 src、选中环变白)且主窗口跨窗同步;深/浅两态对设置壳 4 个表面 + 主窗口 5 个表面采样,背景明度与文字对比度全部达标(读性 ≥4.5);深色下重开设置窗口仍持久化,浅色可还原;预览卡渐变随主题重绘;无渲染器报错。报告 `.tmp/appearance-settings-vxGNgk/report.json`。 +回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-settings-nav.cjs`、`node scripts/verify-general-settings.cjs`、`node scripts/verify-desktop-appearance.cjs`、`node scripts/verify-account-menu.cjs` 全部通过(三个旧脚本对新外观组件的选择器断言已同步更新)。 + +遗留: +1. YCode 只保留「浅色/深色」两张主题卡;原版另外 7 张(有风、经典 QQ、和平精英激战金秋、同行、涟漪、伙伴、敬请期待)未接入,不臆造其资源。 +2. 预览图直接使用原版静态截图资源(含 V5.0.2 角标),不是 YCode 的渲染实拍。 +3. 主题切换的过渡动画未连续采集(复合探针 REA 返回 `target_changed`),只验证了稳定深浅两态。 +4. 主题覆盖采样未包含:独立任务窗口、弹层、代码/表格/工具结果等表面;后续如需可沿用同样的采样方法补齐。 +5. 原版 tokens 表中不存在的变量(`--wb-color-fill-hover/active/secondary`、`--wb-color-bg-primary`、`--wb-radius-2`、`--cb-*` 等)未臆造取值,规则保留 fallback。 ### 12. 快捷键页面与真实功能 diff --git a/scripts/rea/probe-todo11-appearance.json b/scripts/rea/probe-todo11-appearance.json new file mode 100644 index 0000000..66463d5 --- /dev/null +++ b/scripts/rea/probe-todo11-appearance.json @@ -0,0 +1,17 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-modal", + ".settings-navigation", + ".settings-navigation__item", + ".settings-modal__panel", + "[class*=appearance]" + ], + "actions": [ + { "kind": "click", "step_id": "select-appearance-tab", "selector": ".settings-navigation__item[data-settings-tab=\"appearance\"]", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "appearance-ready", "duration_ms": 600, "frames": 2 } + ] +} diff --git a/scripts/rea/probe-todo11-general-dark.json b/scripts/rea/probe-todo11-general-dark.json new file mode 100644 index 0000000..356d3ee --- /dev/null +++ b/scripts/rea/probe-todo11-general-dark.json @@ -0,0 +1,21 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".general-settings-panel", + ".general-settings-panel__group-card", + ".general-settings-panel__section-title", + ".general-settings-panel__section-desc", + ".wb-select", + ".wb-switch" + ], + "actions": [ + { "kind": "click", "step_id": "select-general-dark", "selector": ".settings-navigation__item", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "general-dark-ready", "duration_ms": 500, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo11-general-light.json b/scripts/rea/probe-todo11-general-light.json new file mode 100644 index 0000000..ffb47fc --- /dev/null +++ b/scripts/rea/probe-todo11-general-light.json @@ -0,0 +1,21 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".general-settings-panel", + ".general-settings-panel__group-card", + ".general-settings-panel__section-title", + ".general-settings-panel__section-desc", + ".wb-select", + ".wb-switch" + ], + "actions": [ + { "kind": "click", "step_id": "select-general-light", "selector": ".settings-navigation__item", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "general-light-ready", "duration_ms": 500, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo11-open-appearance-dark.json b/scripts/rea/probe-todo11-open-appearance-dark.json new file mode 100644 index 0000000..33f57fd --- /dev/null +++ b/scripts/rea/probe-todo11-open-appearance-dark.json @@ -0,0 +1,16 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".appearance-panel" + ], + "actions": [ + { "kind": "click", "step_id": "select-appearance-dark", "selector": ".settings-navigation__item[data-settings-tab=\"appearance\"]", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "appearance-dark-ready", "duration_ms": 500, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo11-preview-media.json b/scripts/rea/probe-todo11-preview-media.json new file mode 100644 index 0000000..be7d0cc --- /dev/null +++ b/scripts/rea/probe-todo11-preview-media.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "media_selectors": [".appearance-preview__image"], + "actions": [ + { "kind": "wait", "step_id": "preview-media", "duration_ms": 400, "frames": 0 } + ] +} diff --git a/scripts/rea/probe-todo11-restore-light.json b/scripts/rea/probe-todo11-restore-light.json new file mode 100644 index 0000000..ed021c0 --- /dev/null +++ b/scripts/rea/probe-todo11-restore-light.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [], + "actions": [ + { "kind": "click", "step_id": "restore-light-theme", "selector": ".appearance-card--light", "index": 0, "frames": 0 } + ] +} diff --git a/scripts/rea/probe-todo11-theme-dark-only.json b/scripts/rea/probe-todo11-theme-dark-only.json new file mode 100644 index 0000000..9795a3e --- /dev/null +++ b/scripts/rea/probe-todo11-theme-dark-only.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [], + "actions": [ + { "kind": "click", "step_id": "select-dark-theme", "selector": ".appearance-card--dark", "index": 0, "frames": 0 } + ] +} diff --git a/scripts/rea/probe-todo11-theme-dark-sample.json b/scripts/rea/probe-todo11-theme-dark-sample.json new file mode 100644 index 0000000..41ddc26 --- /dev/null +++ b/scripts/rea/probe-todo11-theme-dark-sample.json @@ -0,0 +1,21 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-window-chrome", + ".settings-modal", + ".settings-navigation", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".appearance-preview", + ".appearance-badge", + ".appearance-list__title", + ".appearance-card--selected" + ], + "actions": [ + { "kind": "wait", "step_id": "dark-theme-stable", "duration_ms": 800, "frames": 4 } + ] +} diff --git a/scripts/rea/probe-todo11-theme-light-sample.json b/scripts/rea/probe-todo11-theme-light-sample.json new file mode 100644 index 0000000..13c18ef --- /dev/null +++ b/scripts/rea/probe-todo11-theme-light-sample.json @@ -0,0 +1,21 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-window-chrome", + ".settings-modal", + ".settings-navigation", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".appearance-preview", + ".appearance-badge", + ".appearance-list__title", + ".appearance-card--selected" + ], + "actions": [ + { "kind": "wait", "step_id": "light-theme-restored", "duration_ms": 500, "frames": 2 } + ] +} diff --git a/scripts/rea/probe-todo11-theme-switch.json b/scripts/rea/probe-todo11-theme-switch.json new file mode 100644 index 0000000..16e8646 --- /dev/null +++ b/scripts/rea/probe-todo11-theme-switch.json @@ -0,0 +1,24 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-window-root", + ".settings-window-chrome", + ".settings-modal", + ".settings-navigation", + ".settings-navigation__item--active", + ".settings-modal__panel", + ".appearance-preview", + ".appearance-badge", + ".appearance-list__title", + ".appearance-card--selected" + ], + "actions": [ + { "kind": "click", "step_id": "select-dark-theme", "selector": ".appearance-card--dark", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "dark-theme-settled", "duration_ms": 800, "frames": 4 }, + { "kind": "click", "step_id": "restore-light-theme", "selector": ".appearance-card--light", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "light-theme-restored", "duration_ms": 500, "frames": 2 } + ] +} diff --git a/scripts/rea/recover-appearance-panel.mjs b/scripts/rea/recover-appearance-panel.mjs new file mode 100644 index 0000000..e4d4a27 --- /dev/null +++ b/scripts/rea/recover-appearance-panel.mjs @@ -0,0 +1,80 @@ +// Recover only already-retained REA CSS; never access the installed application here. +// +// Source: REA evidence ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99 +// (WorkBuddy 5.8.3 设置 → 外观), 模块样式表 appearance-DMmhwsxq.css(原版懒加载,只有进入外观页才请求)。 +// 面板 DOM 与几何来自同一批 REA 证据 `.tmp/todo11-wb-appearance/layout.json`: +// .appearance-panel [232,40,638,640]、.appearance-preview 634×317、卡片网格 5 列 118.8×101.2、卡片 118.8×79.2。 +import fs from 'node:fs/promises' +import { createHash } from 'node:crypto' +import postcss from 'postcss' +const sha = value => createHash('sha256').update(value).digest('hex') +const evidenceId = 'ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99' +const evidence = JSON.parse(await fs.readFile(`.tmp/rea-css-evidence/${evidenceId}.json`, 'utf8')) +if (evidence.evidence_id !== evidenceId) throw new Error('REA identity mismatch') +const module = evidence.normalized_result.styles.items.find(item => /appearance-DMmhwsxq\.css$/.test(item.file_path)) +if (!module) throw new Error('Appearance module CSS is absent from the evidence') +const moduleCss = module.source.artifact.text +const moduleSha = sha(moduleCss) +if (moduleSha !== 'af58cb3134d3e6c474424f6d718daa8f5be0c6e29d40aa5f9f80c14b3ffbbbd2') throw new Error('Appearance module CSS changed') +const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8') +const tokensSha = sha(tokens) +if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed') + +// 原版外观面板自己的 token 定义(--wb-text-strong 等)在 tokens 样式表里,按组件作用域复制一份。 +const lightTokens = new Map() +const darkTokens = new Map() +for (const rule of postcss.parse(tokens).nodes) { + if (rule.type !== 'rule') continue + const selectors = rule.selectors ?? [] + const dark = selectors.some(selector => selector.includes('[data-theme=dark]')) + const light = selectors.some(selector => selector.trim() === ':root') + if (!dark && !light) continue + for (const node of rule.nodes ?? []) { + if (node.type !== 'decl' || !node.prop.startsWith('--')) continue + if (dark) darkTokens.set(node.prop, node.value) + if (light) lightTokens.set(node.prop, node.value) + } +} + +// 只保留外观面板相关规则;同时把原版的深色选择器(.dark / [data-cb-theme=dark])改写成 YCode 的 [data-theme=dark]。 +const darkSelector = /(^|[\s,>+~])\.dark\b|\[data-cb-theme=dark\]/g +function project(node) { + if (node.type === 'rule') { + const selectors = [] + for (const selector of node.selectors) { + if (!/\.appearance-/.test(selector)) continue + const mapped = selector.replace(darkSelector, (match, prefix) => `${prefix ?? ''}[data-theme=dark]`) + if (!selectors.includes(mapped)) selectors.push(mapped) + } + return selectors.length ? node.clone({ selector: selectors.join(',') }) : null + } + if (node.type === 'atrule' && node.nodes) { + const nodes = node.nodes.map(project).filter(Boolean) + return nodes.length ? node.clone({ nodes }) : null + } + return null +} +const root = postcss.root({ nodes: postcss.parse(moduleCss).nodes.map(project).filter(Boolean) }) +const referenced = new Set() +const collect = (text) => { for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) } +collect(root.toString()) +for (let depth = 0; depth < 6; depth++) { + const before = referenced.size + for (const name of [...referenced]) { + const value = lightTokens.get(name) ?? darkTokens.get(name) + if (typeof value === 'string') collect(value) + } + if (referenced.size === before) break +} +const missing = [...referenced].filter(name => !lightTokens.has(name)) +if (missing.length) console.log(`Tokens missing from light root scope: ${missing.join(', ')}`) +const pick = (map) => new Map([...referenced].filter(name => map.has(name)).map(name => [name, map.get(name)])) +const scope = (name, map) => `.${name}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}` +const scopedTokens = `${scope('appearance-panel', pick(lightTokens))}\n:root[data-theme=dark] ${scope('appearance-panel', pick(darkTokens))}\n` +const css = `/* REA ${evidenceId}; appearance-DMmhwsxq.css sha256=${moduleSha}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${scopedTokens}` +await fs.writeFile('src/renderer/src/components/source-appearance-panel-recovered.css', css) +const rules = [] +root.walkRules(rule => rules.push(rule.selector)) +console.log(`Recovered ${rules.length} appearance rules (${css.length} bytes); scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`) +console.log(rules.slice(0, 10).join('\n')) +if (rules.length < 20) throw new Error('Unexpectedly few appearance rules') diff --git a/scripts/rea/recover-settings-shell.mjs b/scripts/rea/recover-settings-shell.mjs index 4006224..dda4aa1 100644 --- a/scripts/rea/recover-settings-shell.mjs +++ b/scripts/rea/recover-settings-shell.mjs @@ -11,6 +11,9 @@ const windowCss = module.source.artifact.text if (sha(windowCss) !== '8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986') throw new Error('Window CSS changed') const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8') if (sha(contexts) !== '50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380') throw new Error('Contexts CSS changed') +const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8') +const tokensSha = sha(tokens) +if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed') function project(node) { if (node.type === 'rule') { const selectors = node.selectors.filter(selector => /\.settings-(modal|navigation)/.test(selector)) @@ -23,5 +26,42 @@ function project(node) { return null } const root = postcss.root({ nodes: postcss.parse(contexts).nodes.map(project).filter(Boolean) }) -await fs.writeFile('src/renderer/src/components/settings-shell-source.css', `/* REA ${evidenceId}; settings-window CSS sha256=${sha(windowCss)}; contexts sha256=${sha(contexts)}. */\n${root.toString()}\n${windowCss}\n`) -console.log('Recovered settings shell CSS with original media conditions') +// 原版把 `--wb-*` 设计变量放在全局 :root(tokens 样式表);YCode 没有全局 token 表,按组件作用域复制。 +// 设置窗口的根元素是 `.settings-window-root`(原生设置窗口)或 `.settings-modal-overlay`(主窗口内浮层), +// 而 `.settings-modal` 自身也直接引用这些变量(背景、圆角、滚动条),因此两处都定义一份, +// 深色块让 [data-theme=dark] 下的窗口背景/文字/滚动条随主题切换(TODO 11)。 +const lightTokens = new Map() +const darkTokens = new Map() +for (const rule of postcss.parse(tokens).nodes) { + if (rule.type !== 'rule') continue + const selectors = rule.selectors ?? [] + const dark = selectors.some(selector => selector.includes('[data-theme=dark]')) + const light = selectors.some(selector => selector.trim() === ':root') + if (!dark && !light) continue + for (const node of rule.nodes ?? []) { + if (node.type !== 'decl' || !node.prop.startsWith('--')) continue + if (dark) darkTokens.set(node.prop, node.value) + if (light) lightTokens.set(node.prop, node.value) + } +} +const referenced = new Set() +const collect = (text) => { for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) } +collect(root.toString()) +collect(windowCss) +for (let depth = 0; depth < 6; depth++) { + const before = referenced.size + for (const name of [...referenced]) { + const value = lightTokens.get(name) ?? darkTokens.get(name) + if (typeof value === 'string') collect(value) + } + if (referenced.size === before) break +} +const missing = [...referenced].filter(name => !lightTokens.has(name)) +if (missing.length) console.log(`Tokens missing from light root scope: ${missing.join(', ')}`) +const pick = (map) => new Map([...referenced].filter(name => map.has(name)).map(name => [name, map.get(name)])) +const scope = (selectors, map) => `${selectors}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}` +const shellScope = '.settings-window-root,.settings-modal' +const darkScope = ':root[data-theme=dark] .settings-window-root,:root[data-theme=dark] .settings-modal' +const scopedTokens = `${scope(shellScope, pick(lightTokens))}\n${scope(darkScope, pick(darkTokens))}\n` +await fs.writeFile('src/renderer/src/components/settings-shell-source.css', `/* REA ${evidenceId}; settings-window CSS sha256=${sha(windowCss)}; contexts sha256=${sha(contexts)}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${windowCss}\n${scopedTokens}`) +console.log(`Recovered settings shell CSS with original media conditions; scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`) diff --git a/scripts/verify-account-menu.cjs b/scripts/verify-account-menu.cjs index e13679b..689b035 100644 --- a/scripts/verify-account-menu.cjs +++ b/scripts/verify-account-menu.cjs @@ -237,12 +237,12 @@ if (!process.versions.electron) { await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click') await openSettings('appearance') observe(settings) - await wait('!!document.querySelector(".segmented")', 'appearance panel', settings) - check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>node.textContent)`, settings), ['浅色', '深色']) - check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>[node.textContent,node.dataset.active])`, settings), [['浅色', 'true'], ['深色', 'false']]) + await wait('!!document.querySelector(".appearance-panel")', 'appearance panel', settings) + check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>node.getAttribute('aria-label'))`, settings), ['浅色', '深色']) + check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>[node.getAttribute('aria-label'),String(node.classList.contains('appearance-card--selected'))])`, settings), [['浅色', 'true'], ['深色', 'false']]) await step('menu-dark-theme') - await clickText('.segmented__item', '深色', settings) + await mouseClick('[aria-label="深色"].appearance-card', settings) await wait(`document.documentElement.dataset.theme==='dark'`, 'main window receives dark theme') check('appearance row drives the shared theme', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['dark', 'dark']) await read(openMenu) @@ -262,12 +262,12 @@ if (!process.versions.electron) { await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after settings click') await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings) check('设置 row reuses the native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1) - check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.segmented')`, settings), true) + check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true) await step('menu-light-restore') await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings) - await wait('!!document.querySelector(".segmented")', 'appearance panel again', settings) - await clickText('.segmented__item', '浅色', settings) + await wait('!!document.querySelector(".appearance-panel")', 'appearance panel again', settings) + await mouseClick('[aria-label="浅色"].appearance-card', settings) await wait(`document.documentElement.dataset.theme==='light'`, 'main window receives light theme') check('light theme is restored through the same settings window', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['light', 'light']) const settingsId = settings.id diff --git a/scripts/verify-appearance-settings.cjs b/scripts/verify-appearance-settings.cjs new file mode 100644 index 0000000..57128aa --- /dev/null +++ b/scripts/verify-appearance-settings.cjs @@ -0,0 +1,280 @@ +// TODO 11 验收:设置 → 外观页的 1:1 几何 + 主题切换(即时生效 / 跨窗口同步 / 重启持久化), +// 并抽查各已接表面的深浅一致性(无浅深混色、无可读性回归)。 +// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。 +// +// 原版依据(REA ev_dee8f184…/ev_24ab5853…/ev_ff4f073e…/ev_fa67fddb…): +// .appearance-panel [232,40,638,640]、.appearance-preview [236,40,634,317]、 +// 预览图 [308.906,86.281,488.172,270.719]、徽标「永久有效」[806,48,56,20]、 +// 列表标题「全部主题」[236,381,56,20]、网格 [236,413,634,218.391]、卡片 [x,417,118.797,79.188]、 +// 名称 [x,500.188,118.797,18];原版共 9 张卡,用户限定 YCode 只保留 浅色/深色 两张。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +const EXPECTED = { + panel: [232, 40, 638, 640], + preview: [236, 40, 634, 317], + image: [308.906, 86.281, 488.172, 270.719], + badge: [806, 48, 56, 20], + headerTitle: [236, 381, 56, 20], + grid: [236, 413, 634, 218.391], + card: [118.797, 79.188], + name: [118.797, 18] +} + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/appearance-settings-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + await fs.mkdir(data, { recursive: true }) + await fs.mkdir(home, { recursive: true }) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, + YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('APPEARANCE_SETTINGS_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 300000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output.slice(-4000)) + process.exitCode = code ? 1 : 0 + }) + })().catch(error => { console.error(error); process.exitCode = 1 }) +} else { + const { app, BrowserWindow } = require('electron') + const { pathToFileURL } = require('node:url') + const directory = process.env.YCODE_QA_DIRECTORY + const dataDir = process.env.YCODE_USER_DATA_DIR + app.setPath('home', path.join(directory, 'home')) + const checks = [], snapshots = {}, rendererErrors = [] + let main, settings, phase = 'boot' + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source, win = main) { + let timer + try { + return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000) + })]) + } finally { clearTimeout(timer) } + } + async function wait(source, label, win = main) { + for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + const near = (value, target, tolerance = 0.6) => Math.abs(value - target) <= tolerance + async function openSettings(section) { + await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) + for (let index = 0; index < 200; index++) { + settings = BrowserWindow.getAllWindows().find(win => win !== main) + if (settings && !settings.webContents.isLoading()) break + await pause(25) + } + assert.ok(settings, 'settings window') + settings.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) + await pause(400) + return settings + } + + // 与原版同源的布局测量 + 主题一致性采样(透明背景向上取最近的不透明祖先)。 + const layoutSource = `(()=>{ + const rect = node => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height } } + const round = value => Math.round(value * 1000) / 1000 + const shape = node => { if (!node) return null; const r = rect(node); return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } } + const panel = document.querySelector('.appearance-panel') + const cards = [...document.querySelectorAll('.appearance-card-cell')] + return { + panel: shape(panel), + preview: shape(document.querySelector('.appearance-preview')), + previewBackground: (() => { const node = document.querySelector('.appearance-preview'); return node ? getComputedStyle(node).backgroundImage : '' })(), + image: shape(document.querySelector('.appearance-preview__image')), + badge: (() => { const node = document.querySelector('.appearance-badge--validity'); return node ? { ...shape(node), text: node.textContent.trim() } : null })(), + header: shape(document.querySelector('.appearance-list__header')), + headerTitle: (() => { const node = document.querySelector('.appearance-list__title'); return node ? { ...shape(node), text: node.textContent.trim() } : null })(), + grid: shape(document.querySelector('.appearance-card-grid')), + cards: cards.map(cell => ({ cell: shape(cell), card: shape(cell.querySelector('.appearance-card')), + name: shape(cell.querySelector('.appearance-card__name')), label: cell.querySelector('.appearance-card__name')?.textContent?.trim(), + selected: cell.querySelector('.appearance-card')?.classList.contains('appearance-card--selected'), + variant: [...(cell.querySelector('.appearance-card')?.classList ?? [])].find(name => name.startsWith('appearance-card--')) })), + imageSrc: document.querySelector('.appearance-preview__image')?.getAttribute('src') ?? '', + theme: document.documentElement.dataset.theme ?? 'light', + selectedOutline: (() => { const node = document.querySelector('.appearance-card--selected'); if (!node) return null + const style = getComputedStyle(node); return [style.outlineStyle, style.outlineWidth, style.outlineColor] })() + }})()` + + const themeSource = selectors => `(()=>{ + const parse = value => { const match = /rgba?\\(([^)]+)\\)/.exec(value); if (!match) return null + const parts = match[1].split(',').map(part => Number(part.trim())); return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 } } + const effective = node => { let current = node; while (current) { const color = parse(getComputedStyle(current).backgroundColor) + if (color && color.a > 0.5) return color; current = current.parentElement } return { r: 255, g: 255, b: 255, a: 1 } } + const luminance = color => { const channel = value => { const c = value / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } + return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b) } + const contrast = (a, b) => { const l1 = luminance(a), l2 = luminance(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05) } + const selectors = ${JSON.stringify(selectors)} + const samples = {} + for (const selector of selectors) { + const node = document.querySelector(selector) + if (!node) continue + const style = getComputedStyle(node) + const background = effective(node) + const foreground = parse(style.color) ?? { r: 0, g: 0, b: 0, a: 1 } + samples[selector] = { background, foreground, backgroundLuminance: Math.round(luminance(background) * 1000) / 1000, + contrast: Math.round(contrast(background, foreground) * 100) / 100, hasText: (node.textContent ?? '').trim().length > 0 } + } + return { theme: document.documentElement.dataset.theme ?? 'light', samples } + })()` + const SETTINGS_SURFACES = ['.settings-window-root', '.settings-modal', '.settings-modal__nav', '.settings-modal__panel', + '.appearance-card--selected', '.appearance-card__name'] + const MAIN_SURFACES = ['.sidebar', '.chat-pane', '.wb-composer--home', '.detail-panel', '.sidebar-next'] + + ;(async () => { + const sync = require('node:fs') + sync.mkdirSync(dataDir, { recursive: true }) + app.setPath('userData', dataDir) + app.setPath('sessionData', dataDir) + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) + await app.whenReady() + for (let index = 0; index < 200; index++) { + main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') + if (main && !main.webContents.isLoading()) break + await pause(40) + } + assert.ok(main, 'main window') + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!window.ycode', 'main renderer') + await pause(1200) + + await step('appearance-layout') + await openSettings('appearance') + const light = await read(layoutSource, settings) + snapshots.light = light + check('外观 nav entry is enabled and active', + await read(`(()=>{const item=[...document.querySelectorAll('.settings-navigation__item')].find(node=>node.dataset.settingsTab==='appearance') + return [item?.getAttribute('aria-disabled') !== 'true', document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab]})()`, settings), + [true, 'appearance']) + check('panel keeps the recovered 232,40 638x640 box', + [near(light.panel.x, EXPECTED.panel[0]), near(light.panel.y, EXPECTED.panel[1]), near(light.panel.width, EXPECTED.panel[2]), near(light.panel.height, EXPECTED.panel[3])], + [true, true, true, true]) + check('preview keeps the recovered 634x317 card and 488x271 image', + [[near(light.preview.x, EXPECTED.preview[0]), near(light.preview.y, EXPECTED.preview[1]), near(light.preview.width, EXPECTED.preview[2]), near(light.preview.height, EXPECTED.preview[3])].every(Boolean), + [near(light.image.x, EXPECTED.image[0], 1.5), near(light.image.y, EXPECTED.image[1], 1.5), near(light.image.width, EXPECTED.image[2], 1.5), near(light.image.height, EXPECTED.image[3], 1.5)].every(Boolean)], + [true, true]) + check('预览有效期徽标 keeps 永久有效 at the recovered 56x20 top-right', + [light.badge.text, near(light.badge.x, EXPECTED.badge[0]), near(light.badge.y, EXPECTED.badge[1]), near(light.badge.width, EXPECTED.badge[2]), near(light.badge.height, EXPECTED.badge[3])], + ['永久有效', true, true, true, true]) + check('列表标题 keeps 全部主题 at the recovered 56x20 position', + [light.headerTitle.text, near(light.headerTitle.x, EXPECTED.headerTitle[0]), near(light.headerTitle.y, EXPECTED.headerTitle[1])], + ['全部主题', true, true]) + // 原版网格 634×218.391 是 9 张卡两行;YCode 只保留 浅色/深色(1 行), + // 因此这里断言列位置/宽度与单行高度(4px padding + 101.19 cell),并单独断言行位置。 + check('header keeps the recovered 634x56 and the grid keeps its recovered origin', + [near(light.header.x, 236), near(light.header.y, 357), near(light.header.width, 634), near(light.header.height, 56), near(light.grid.y, EXPECTED.grid[1])], + [true, true, true, true, true]) + // 原版两行 218.391;YCode 只有两张卡 = 一行:4px padding + 101.19 行高。 + check('grid keeps the recovered column origin/width with a single row', + [near(light.grid.x, EXPECTED.grid[0]), near(light.grid.width, EXPECTED.grid[2]), near(light.grid.height, 105.19, 2)], + [true, true, true]) + check('cards keep the recovered first-row cell geometry', + light.cards.map((card, index) => [near(card.card.x, index === 0 ? 240 : 366.797), near(card.card.y, 417), + near(card.card.width, EXPECTED.card[0]), near(card.card.height, EXPECTED.card[1])].every(Boolean)), + [true, true]) + check('keeps exactly the two requested theme cards with recovered card/label sizes', + [light.cards.map(card => card.label), light.cards.map(card => [near(card.card.width, EXPECTED.card[0]), near(card.card.height, EXPECTED.card[1])].every(Boolean)), + light.cards.map(card => [near(card.name.width, EXPECTED.name[0]), near(card.name.height, EXPECTED.name[1])].every(Boolean))], + [['浅色', '深色'], [true, true], [true, true]]) + check('first card is the selected light theme with the recovered outline', + [light.cards[0].selected, light.cards[1].selected, light.selectedOutline[0], light.selectedOutline[1]], + [true, false, 'solid', '2px']) + + await step('appearance-theme-switch') + const darkSettings = await read(`(async()=>{const card=document.querySelectorAll('.appearance-card')[1] + card.click() + await new Promise(resolve => setTimeout(resolve, 500)) + const image=document.querySelector('.appearance-preview__image') + return { theme: document.documentElement.dataset.theme, stored: localStorage.getItem('ycode:theme'), + selected: document.querySelector('.appearance-card--selected')?.classList.contains('appearance-card--dark'), + src: image.getAttribute('src'), outline: getComputedStyle(document.querySelector('.appearance-card--selected')).outlineColor }})()`, settings) + snapshots.darkSettings = darkSettings + await pause(600) + const darkLayout = await read(layoutSource, settings) + snapshots.darkLayout = darkLayout + const mainDark = await read(`(async()=>{const theme=document.documentElement.dataset.theme + const sidebar=getComputedStyle(document.querySelector('.sidebar')).backgroundColor + return { theme, sidebar }})()`) + check('clicking 深色 switches the settings window immediately and persists shared storage', + [darkSettings.theme, darkSettings.stored, darkSettings.selected], ['dark', 'dark', true]) + check('main window follows the shared appearance (cross-window sync)', + [mainDark.theme, mainDark.sidebar !== 'rgb(255, 255, 255)' && mainDark.sidebar !== 'rgb(247, 247, 247)'], ['dark', true]) + check('preview swaps to the dark preview image and the selected ring turns light', + [darkLayout.imageSrc !== light.imageSrc, darkLayout.selectedOutline[2]], [true, 'rgb(255, 255, 255)']) + check('dark layout keeps the recovered panel geometry', + [near(darkLayout.panel.width, EXPECTED.panel[2]), near(darkLayout.panel.height, EXPECTED.panel[3]), near(darkLayout.preview.height, EXPECTED.preview[3])], + [true, true, true]) + + await step('appearance-theme-audit') + const darkAudit = { settings: await read(themeSource(SETTINGS_SURFACES), settings), main: await read(themeSource(MAIN_SURFACES)) } + snapshots.darkAudit = darkAudit + const darkSurfaces = [...Object.entries(darkAudit.settings.samples), ...Object.entries(darkAudit.main.samples)] + check('dark theme keeps every sampled surface dark and legible', + darkSurfaces.map(([selector, sample]) => ({ selector, dark: sample.backgroundLuminance < 0.25, readable: !sample.hasText || sample.contrast >= 4.5 })), + darkSurfaces.map(([selector]) => ({ selector, dark: true, readable: true }))) + await read(`(async()=>{document.querySelectorAll('.appearance-card')[0].click() + await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) + await pause(500) + const lightAudit = { settings: await read(themeSource(SETTINGS_SURFACES), settings), main: await read(themeSource(MAIN_SURFACES)) } + snapshots.lightAudit = lightAudit + const lightSurfaces = [...Object.entries(lightAudit.settings.samples), ...Object.entries(lightAudit.main.samples)] + check('light theme keeps every sampled surface light and legible', + lightSurfaces.map(([selector, sample]) => ({ selector, light: sample.backgroundLuminance > 0.7, readable: !sample.hasText || sample.contrast >= 4.5 })), + lightSurfaces.map(([selector]) => ({ selector, light: true, readable: true }))) + check('theme audit covers the recovered settings shell and the main window surfaces', + [['.settings-window-root', '.settings-modal', '.settings-modal__nav', '.settings-modal__panel'].every(selector => selector in lightAudit.settings.samples), + Object.keys(lightAudit.main.samples).length >= 2], + [true, true]) + check('preview card repaints its gradient between themes', + [light.previewBackground !== darkLayout.previewBackground, /gradient/.test(light.previewBackground)], + [true, true]) + + await step('appearance-persistence') + await read(`(async()=>{document.querySelectorAll('.appearance-card')[1].click() + await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) + await pause(500) + settings.close() + await pause(600) + await openSettings('appearance') + const reopened = await read(layoutSource, settings) + check('reopened settings window restores the persisted dark theme with the 深色 card selected', + [reopened.theme, reopened.cards[1].selected, reopened.cards[0].selected], ['dark', true, false]) + await read(`(async()=>{document.querySelectorAll('.appearance-card')[0].click() + await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) + await pause(400) + const restored = await read(layoutSource, settings) + check('selecting 浅色 restores the light theme and shared storage', + [restored.theme, restored.cards[0].selected], + ['light', true]) + + await step('appearance-errors') + check('no renderer error logs', rendererErrors, []) + + const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL' + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2)) + console.log('APPEARANCE_SETTINGS_' + result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('APPEARANCE_SETTINGS_FAILURE ' + JSON.stringify(failure)) + app.exit(result === 'PASS' ? 0 : 1) + })().catch(async error => { + console.error(error) + try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ } + app.exit(1) + }) +} diff --git a/scripts/verify-desktop-appearance.cjs b/scripts/verify-desktop-appearance.cjs index dbd853b..ba5068f 100644 --- a/scripts/verify-desktop-appearance.cjs +++ b/scripts/verify-desktop-appearance.cjs @@ -92,7 +92,7 @@ if (!process.versions.electron) { await step('ui-dark-theme-and-reused-settings-navigation') await openSettings('appearance'); observe(settings) await click('[data-settings-tab="appearance"]', settings) - await clickText('.segmented__item', '深色', settings) + await click('[aria-label="深色"].appearance-card', settings) await wait('document.documentElement.dataset.theme==="dark"', 'main receives dark appearance') let theme = await themeSnapshot('dark-selected-in-native-settings') check('native UI dark changes both renderers and persistent shared storage', [theme.main, theme.settings], [['dark', 'dark'], ['dark', 'dark']]) @@ -100,7 +100,7 @@ if (!process.versions.electron) { const previous = settings.id await openSettings('model'); await wait('!!document.querySelector(".models-settings-panel")', 'reused settings navigates to actual models panel', settings) check('openSettings(model) reuses existing native window', settings.id, previous) - check('reused settings actually navigated away from appearance', await read('!!document.querySelector(".models-settings-panel")&&!document.querySelector(".segmented")', settings), true) + check('reused settings actually navigated away from appearance', await read('!!document.querySelector(".models-settings-panel")&&!document.querySelector(".appearance-panel")', settings), true) await click('.settings-window-close', settings) for (let i = 0; i < 150 && !settings.isDestroyed(); i++) await pause(20) check('settings close destroys actual native window', settings.isDestroyed(), true) @@ -108,7 +108,7 @@ if (!process.versions.electron) { check('reopened settings uses a fresh native window', settings.id !== previous, true) theme = await themeSnapshot('reopened-settings-persists-dark') check('native settings reopen preserves shared dark appearance', [theme.main, theme.settings], [['dark', 'dark'], ['dark', 'dark']]) - await clickText('.segmented__item', '浅色', settings) + await click('[aria-label="浅色"].appearance-card', settings) await wait('document.documentElement.dataset.theme==="light"', 'main receives light appearance') theme = await themeSnapshot('light-restored-in-native-settings') check('native UI light restores both renderers and storage', [theme.main, theme.settings], [['light', 'light'], ['light', 'light']]) diff --git a/scripts/verify-settings-nav.cjs b/scripts/verify-settings-nav.cjs index 14a44e2..23397f0 100644 --- a/scripts/verify-settings-nav.cjs +++ b/scripts/verify-settings-nav.cjs @@ -144,8 +144,8 @@ if (!process.versions.electron) { await step('settings-navigation-still-works') await read(`(()=>{const node=document.querySelector('[data-settings-tab="appearance"]');node.click();return true})()`, settings) - await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings) - check('appearance entry still opens its panel', await read(`!!document.querySelector('.segmented')&&!document.querySelector('.models-settings-panel')`, settings), true) + await wait(`!!document.querySelector('.appearance-panel')`, 'appearance panel', settings) + check('appearance entry still opens its panel', await read(`!!document.querySelector('.appearance-panel')&&!document.querySelector('.models-settings-panel')`, settings), true) await read(`(()=>{const node=document.querySelector('[data-settings-tab="models"]');node.click();return true})()`, settings) await wait(`!!document.querySelector('.models-settings-panel')`, 'models panel', settings) check('models entry still opens its panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true) diff --git a/src/renderer/src/components/AppearanceSettingsPanel.tsx b/src/renderer/src/components/AppearanceSettingsPanel.tsx new file mode 100644 index 0000000..f256293 --- /dev/null +++ b/src/renderer/src/components/AppearanceSettingsPanel.tsx @@ -0,0 +1,62 @@ +/** + * 设置 → 外观:WorkBuddy 原版外观页的 1:1 复刻。 + * 用户限定范围(TODO 11):只保留「浅色 / 深色」两张主题卡,原版的其它主题(有风、经典 QQ、 + * 和平精英激战金秋、同行、涟漪、伙伴)与「敬请期待」占位不接入,也不臆造。 + * + * REA 依据(WorkBuddy 5.8.3,设置窗口 900×680): + * - ev_dee8f184f6d8de34338f454e3a2588c26e2621dbc29c07c65337c475a243a6a0:外观页最终浅色态 + * - ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99:外观页深色态(含 appearance-DMmhwsxq.css) + * - ev_ff4f073ea843a5f444244393b3e190ccb1803bfec2b32afbfb4edfed9cc7bbc7 / ev_fa67fddbb3dc00dde1609ef8e72d448e586066b74949f97634e1dbd0921599dc:主题切换后的稳定态 + * 原版实测几何:`.appearance-panel` [232,40,638,640]、`.appearance-preview` [236,40,634,317]、 + * 预览图 [308.906,86.281,488.172,270.719](radius 8px 8px 0 0)、有效期徽标「永久有效」[806,48,56,20]、 + * 列表标题「全部主题」[236,381,56,20]、网格 [236,413,634,218.391](gap 12px 8px、padding 4px 4px 0)、 + * 卡片 [x,417,118.797,79.188]、名称 [x,500.188,118.797,18]。 + * 文案、选中描边与预览图内容读自 REA 帧;样式取自 REA 回收的 appearance-DMmhwsxq.css(见 + * source-appearance-panel-recovered.css)。 + */ +import lightPreview from './appearance-assets/workbuddy-appearance-light.png' +import darkPreview from './appearance-assets/workbuddy-appearance-dark.png' +import './source-appearance-panel.css' + +export type AppearanceTheme = 'light' | 'dark' + +const THEMES: ReadonlyArray<{ value: AppearanceTheme; label: string }> = [ + { value: 'light', label: '浅色' }, + { value: 'dark', label: '深色' } +] + +export interface AppearanceSettingsPanelProps { + theme: AppearanceTheme + onThemeChange(theme: AppearanceTheme): void +} + +export default function AppearanceSettingsPanel({ theme, onThemeChange }: AppearanceSettingsPanelProps): React.JSX.Element { + return ( +
+
+
+
+ {/* 原版 IMG 的 alt 为「效果预览」(REA ev_c0b1dacb… / ev_43c82492… 的 selected_node.attributes.alt)。 */} + 效果预览 + 永久有效 +
+
+ 全部主题 +
+
+
+ {THEMES.map((item) => ( +
+
+ ))} +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/SettingsDialog.tsx b/src/renderer/src/components/SettingsDialog.tsx index bd23089..03f4c4d 100644 --- a/src/renderer/src/components/SettingsDialog.tsx +++ b/src/renderer/src/components/SettingsDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useSettingsStore } from '../stores/settings' import ModelSettingsPanel from './ModelSettingsPanel' import GeneralSettingsPanel from './GeneralSettingsPanel' +import AppearanceSettingsPanel from './AppearanceSettingsPanel' import SourceSettingsNavigation from './SourceSettingsNavigation' import { useSharedTheme } from '../useSharedTheme' import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation' @@ -59,7 +60,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi {section === 'settings' && } {section === 'model' && setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />} {section === 'workspace' &&

工作空间

{settings.workspaces.map((space) => )}
} - {section === 'appearance' &&

外观

{(['light', 'dark'] as const).map((value) => )}
} + {section === 'appearance' && } {section === 'about' &&

YCode

{settings.appInfo?.version}

} diff --git a/src/renderer/src/components/appearance-assets/provenance.json b/src/renderer/src/components/appearance-assets/provenance.json new file mode 100644 index 0000000..821b47c --- /dev/null +++ b/src/renderer/src/components/appearance-assets/provenance.json @@ -0,0 +1,28 @@ +{ + "light": { + "file": "workbuddy-appearance-light.png", + "evidenceId": "ev_c0b1dacb5fa65c88f32c2f600f0a7a6731b05e58f88b74677924a53899b17ff7", + "sha256": "0b1f24f382c53fa6d3b364bf89d0bbe62b4c6c403dc5a6263c9c5b7fcec7f925", + "bytes": 113024, + "pixelSize": [ + 1260, + 840 + ], + "source": "WorkBuddy 5.8.3 renderer asset preview-light-*.png (via REA media_selectors)", + "selector": ".appearance-preview__image", + "alt": "效果预览" + }, + "dark": { + "file": "workbuddy-appearance-dark.png", + "evidenceId": "ev_43c82492b3154ca67bcccc0bfb54694d1e4d06e0baf72560e49355747b1ad3f7", + "sha256": "ca133c7200b03a49170658e9661f4b64d8ecdf30f8c6395b7f07dc25eff653b6", + "bytes": 109347, + "pixelSize": [ + 1260, + 840 + ], + "source": "WorkBuddy 5.8.3 renderer asset preview-dark-*.png (via REA media_selectors)", + "selector": ".appearance-preview__image", + "alt": "效果预览" + } +} diff --git a/src/renderer/src/components/appearance-assets/workbuddy-appearance-dark.png b/src/renderer/src/components/appearance-assets/workbuddy-appearance-dark.png new file mode 100644 index 0000000..6cd412b Binary files /dev/null and b/src/renderer/src/components/appearance-assets/workbuddy-appearance-dark.png differ diff --git a/src/renderer/src/components/appearance-assets/workbuddy-appearance-light.png b/src/renderer/src/components/appearance-assets/workbuddy-appearance-light.png new file mode 100644 index 0000000..e89cae7 Binary files /dev/null and b/src/renderer/src/components/appearance-assets/workbuddy-appearance-light.png differ diff --git a/src/renderer/src/components/settings-shell-source.css b/src/renderer/src/components/settings-shell-source.css index 271b4fa..8e620aa 100644 --- a/src/renderer/src/components/settings-shell-source.css +++ b/src/renderer/src/components/settings-shell-source.css @@ -1,4 +1,6 @@ -/* REA ev_40da6df0424193923bae64a8ccb6fca97ea67813d7de671986d4d7e154b848c1; settings-window CSS sha256=8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986; contexts sha256=50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380. */ +/* REA ev_40da6df0424193923bae64a8ccb6fca97ea67813d7de671986d4d7e154b848c1; settings-window CSS sha256=8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986; contexts sha256=50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696. */ .settings-modal-overlay{z-index:1000;animation:fadeIn var(--wb-motion-duration-fast) var(--wb-motion-easing-standard);background:0 0;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.settings-modal-overlay--appearance{background:0 0!important}.settings-modal{--cb-settings-card-background:color-mix(in srgb, var(--cb-vscode-editor-background,#1e1e1e) 96%, #64748b 4%);--cb-settings-card-background-strong:color-mix(in srgb, var(--cb-vscode-editor-background,#1e1e1e) 92%, #64748b 8%);--cb-settings-dialog-background:var(--wb-bg-secondary);--cb-settings-card-border:var(--wb-color-border-primary);background:var(--wb-bg-secondary);border-radius:var(--wb-radius-4xl);width:880px;max-width:92vw;height:720px;max-height:88vh;animation:slideUp var(--wb-motion-duration-base) var(--wb-motion-easing-emphasized);border:none;display:flex;overflow:hidden;box-shadow:0 16px 40px #0000003d,0 8px 32px #00000014}.settings-modal__nav{width:200px;padding:var(--wb-spacing-9) var(--wb-spacing-4) var(--wb-spacing-4);align-items:flex-start;gap:var(--wb-spacing-1);background:var(--wb-bg-secondary);user-select:none;-webkit-app-region:no-drag;scrollbar-width:thin;scrollbar-color:transparent transparent;border-right:none;flex-direction:column;flex-shrink:0;min-height:0;display:flex;overflow-y:auto}.settings-modal__nav:hover{scrollbar-color:var(--wb-scrollbar-thumb-bg) transparent}.settings-modal__header{padding:var(--wb-spacing-9) 20px 16px 16px;user-select:none;-webkit-app-region:no-drag;flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.settings-modal__header-left{align-items:center;gap:var(--wb-spacing-2);min-width:0;display:flex}.settings-modal__header-right{align-items:center;gap:var(--wb-spacing-3);flex-shrink:0;display:flex}.settings-modal__header-extra{-webkit-app-region:no-drag;align-items:center;display:flex}.settings-modal__back{-webkit-app-region:no-drag}.settings-modal__back.wb-button{width:var(--wb-icon-size-lg);height:var(--wb-icon-size-lg);border-radius:6px}.settings-modal__back.wb-button .wb-button__icon{width:var(--wb-icon-size-lg);height:var(--wb-icon-size-lg)}.settings-modal__back.wb-button .wb-button__icon svg{fill:none}.settings-modal__title{color:var(--wb-color-text-primary,#000000e6);font-family:PingFang SC;font-size:16px;font-style:normal;font-weight:500;line-height:24px}.settings-modal__close{-webkit-app-region:no-drag}.settings-modal__close.wb-button{width:var(--wb-icon-size-lg);height:var(--wb-icon-size-lg);color:var(--wb-color-text-secondary,#0009);border-radius:6px;flex-shrink:0}.settings-modal__close.wb-button .wb-button__icon{width:var(--wb-icon-size-lg);height:var(--wb-icon-size-lg)}.settings-modal__content{background:var(--wb-bg-secondary);flex-direction:column;flex:1;display:flex;position:relative;overflow:hidden}.settings-modal__panel{padding:0 var(--wb-spacing-6) var(--wb-spacing-6) var(--wb-spacing-3);scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:transparent transparent;flex:1;min-height:0;overflow-y:auto}.settings-modal__panel::-webkit-scrollbar{width:4px}.settings-modal__panel::-webkit-scrollbar-track{background:0 0}.settings-modal__panel::-webkit-scrollbar-thumb{border-radius:var(--wb-radius-full);background:0 0}.settings-modal__panel:hover::-webkit-scrollbar-thumb{background:var(--wb-scrollbar-thumb)}.settings-modal__panel:hover::-webkit-scrollbar-thumb:hover{background:var(--wb-scrollbar-thumb-hover)}.settings-modal__panel:hover{scrollbar-color:var(--wb-scrollbar-thumb) transparent}.settings-modal__panel .wb-switch__track{--settings-switch-pin-bg:var(--wb-switch-track-bg);--settings-switch-pin-border:transparent;animation:settings-panel-switch-track-paint var(--wb-motion-duration-fast,.15s)}.settings-modal__panel .wb-switch__thumb{--settings-switch-pin-transform:none;animation:settings-panel-switch-thumb-paint var(--wb-motion-duration-fast,.15s)}.settings-modal__panel .wb-switch__input:checked+.wb-switch__track{--settings-switch-pin-bg:var(--wb-switch-track-bg-checked);--settings-switch-pin-border:var(--wb-switch-track-border-color-checked)}.settings-modal__panel .wb-switch__input:checked+.wb-switch__track .wb-switch__thumb{--settings-switch-pin-transform:translateX(var(--wb-switch-thumb-translate))}.settings-modal__panel .wb-switch--danger .wb-switch__input:checked+.wb-switch__track{--settings-switch-pin-bg:var(--wb-switch-track-bg-danger);--settings-switch-pin-border:var(--wb-switch-track-border-color-danger)}.settings-modal__panel .wb-switch--small .wb-switch__input:checked+.wb-switch__track .wb-switch__thumb{--settings-switch-pin-transform:translateX(var(--wb-switch-thumb-translate-small))}.settings-modal__panel .wb-switch--large .wb-switch__input:checked+.wb-switch__track .wb-switch__thumb{--settings-switch-pin-transform:translateX(var(--wb-switch-thumb-translate-large))}.settings-modal__panel .wb-switch:has(.wb-switch__input:disabled) .wb-switch__track{--settings-switch-pin-bg:var(--wb-switch-track-bg-disabled)}.settings-modal__panel .wb-switch:has(.wb-switch__input:disabled) .wb-switch__input:checked+.wb-switch__track{--settings-switch-pin-bg:var(--wb-switch-track-bg-checked-disabled);--settings-switch-pin-border:transparent}.settings-modal--window .settings-modal__panel{padding-top:var(--wb-spacing-3)}@media (width<=480px){.settings-modal{border-radius:0;flex-direction:column;width:100vw;max-width:100vw;height:100vh;max-height:100vh}.settings-modal__nav{flex-direction:column;flex-shrink:0;width:100%}.settings-modal__panel{padding:20px 16px}}body.wb-mobile-layout .settings-modal-overlay{top:var(--wb-mobile-container-top,0);left:var(--wb-mobile-container-left,0);width:var(--wb-mobile-container-width,100vw);height:var(--wb-mobile-container-height,100dvh);bottom:auto;right:auto}body.wb-mobile-layout .settings-modal{box-sizing:border-box;width:var(--wb-mobile-container-width,100vw);max-width:none;height:var(--wb-mobile-container-height,100dvh);border-radius:0;flex-direction:column;min-height:0;max-height:none}body.wb-mobile-layout .settings-modal__content{display:contents}body.wb-mobile-layout .settings-modal__header{padding:var(--wb-spacing-4) var(--wb-spacing-5);background:var(--wb-color-bg-primary-default);order:0}body.wb-mobile-layout .settings-modal__nav{border-right:none;border-bottom:1px solid var(--wb-color-border-primary);flex:none;order:1;align-items:stretch;gap:0;width:100%;min-height:auto;padding:0;overflow:hidden}body.wb-mobile-layout .settings-modal__panel{min-height:0;padding:var(--wb-spacing-5) var(--wb-spacing-4);flex:auto;order:2;overflow-y:auto}[data-theme=dark] .settings-modal,body.vscode-dark .settings-modal,.vscode-dark .settings-modal,body.dark .settings-modal,.dark .settings-modal,[data-theme=dark] .settings-modal__nav,body.vscode-dark .settings-modal__nav,.vscode-dark .settings-modal__nav,body.dark .settings-modal__nav,.dark .settings-modal__nav,[data-theme=dark] .settings-modal__content,body.vscode-dark .settings-modal__content,.vscode-dark .settings-modal__content,body.dark .settings-modal__content,.dark .settings-modal__content{background:var(--wb-bg-modal)}.settings-navigation{gap:var(--wb-spacing-1);flex-direction:column;width:100%;padding:0;display:flex}.settings-navigation__group{gap:var(--wb-spacing-1);flex-direction:column;display:flex}.settings-navigation__group+.settings-navigation__group{margin-top:var(--wb-spacing-4)}.settings-navigation__group+.settings-navigation__group--about{margin-top:0}.settings-navigation__group-title{box-sizing:border-box;height:28px;padding:0 var(--wb-spacing-4);color:var(--wb-color-text-tertiary);font-size:var(--wb-font-caption-size);line-height:var(--wb-font-caption-line-height);user-select:none;align-items:center;display:flex}.settings-navigation__group-divider{border-top:1px solid var(--wb-color-border-primary,#e5e5e5);flex-shrink:0;width:176px;height:0;margin:10px auto}.settings-navigation__item{align-items:center;gap:var(--wb-spacing-3);width:100%;padding:5px var(--wb-spacing-4);border-radius:var(--wb-radius-lg);color:var(--wb-color-text-primary);font-size:var(--wb-font-body-size);line-height:var(--wb-font-body-line-height);cursor:pointer;text-align:left;transition:background var(--wb-motion-duration-fast) var(--wb-motion-easing-standard), color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard);background:0 0;border:none;display:flex}.settings-navigation__item:hover{background:var(--wb-todo-menu-bg-hover)}.settings-navigation__item--active{background:var(--wb-color-bg-primary-hover)}.settings-navigation__item--active .settings-navigation__icon{color:var(--wb-color-text-primary)}.settings-navigation__item--active:hover{background:var(--wb-color-bg-primary-hover)}[data-theme=dark] .settings-navigation__item--active,body.vscode-dark .settings-navigation__item--active,body.dark .settings-navigation__item--active,.dark .settings-navigation__item--active,[data-theme=dark] .settings-navigation__item--active:hover,body.vscode-dark .settings-navigation__item--active:hover,body.dark .settings-navigation__item--active:hover,.dark .settings-navigation__item--active:hover{background:var(--wb-dropdown-item-bg-hover)}.settings-navigation__icon{width:var(--wb-icon-size-md);height:var(--wb-icon-size-md);color:var(--wb-color-text-secondary);flex-shrink:0;justify-content:center;align-items:center;display:flex}.settings-navigation__icon svg{width:var(--wb-icon-size-md);height:var(--wb-icon-size-md);flex-shrink:0}.settings-navigation__legacy-icon-hidden{display:none}.settings-navigation__label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.settings-navigation__red-dot{background:#f54a45;border-radius:50%;flex-shrink:0;width:6px;height:6px;margin-left:-4px}.settings-navigation__badge{color:#fff;background:#f97316;border-radius:9px;justify-content:center;align-items:center;min-width:18px;height:18px;margin-left:auto;padding:0 5px;font-size:11px;font-weight:600;line-height:18px;display:inline-flex}@media (width<=480px){.settings-navigation{border-bottom:1px solid var(--cb-vscode-panel-border,#3c3c3c);flex-direction:row;gap:0;padding:8px;overflow-x:auto}.settings-navigation::-webkit-scrollbar{display:none}.settings-navigation__item{flex:1;justify-content:center;gap:6px;min-width:0;height:36px;padding:0 8px}.settings-navigation__label{font-size:11px}}body.wb-mobile-layout .settings-navigation{overscroll-behavior-x:contain;padding:var(--wb-spacing-2) var(--wb-spacing-3);gap:var(--wb-spacing-1);scrollbar-width:none;flex-direction:row;overflow-x:auto}body.wb-mobile-layout .settings-navigation::-webkit-scrollbar{display:none}body.wb-mobile-layout .settings-navigation__group{gap:var(--wb-spacing-1);flex-direction:row;flex:none;margin-top:0}body.wb-mobile-layout .settings-navigation__group-title{display:none}body.wb-mobile-layout .settings-navigation__item{white-space:nowrap;flex:none;justify-content:center;width:auto;min-width:max-content}body.wb-mobile-layout .settings-navigation__label{white-space:nowrap} .settings-window-root{background:var(--wb-bg-secondary,var(--wb-color-bg-primary,#fff));color:var(--wb-color-text-primary,#1f2329);flex-direction:column;width:100vw;height:100vh;display:flex;overflow:hidden}.settings-window-root .settings-modal--window{box-shadow:none;border-radius:0;flex:1;width:100vw;max-width:none;height:auto;max-height:none}.settings-window-root .settings-modal--window .settings-modal__nav{margin:0 12px 12px;padding:8px 0 0}.settings-window-root .settings-modal__header{-webkit-app-region:drag}.settings-window-root .settings-modal__header button,.settings-window-root .settings-modal__nav,.settings-window-root .settings-modal__panel{-webkit-app-region:no-drag}.settings-window-chrome{-webkit-app-region:drag;user-select:none;flex:0 0 32px;align-items:center;min-height:32px;padding:0 8px;display:flex}.settings-window-chrome--windows{justify-content:flex-end}.settings-window-close{-webkit-app-region:no-drag;border-radius:var(--wb-radius-2,6px);color:var(--wb-color-text-secondary,#646a73);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:inline-flex}.settings-window-close:hover{background:var(--wb-color-fill-hover,#0000000f);color:var(--wb-color-text-primary,#1f2329)}.settings-window-close:active{background:var(--wb-color-fill-active,#0000001a)}.settings-window-loading{background:var(--wb-bg-secondary,var(--wb-color-bg-primary,#fff));flex:1;min-height:0;padding:20px 8px;display:flex}.settings-window-loading__nav{box-sizing:border-box;flex-direction:column;flex:0 0 200px;gap:8px;padding:0 12px;display:flex}.settings-window-loading__content{box-sizing:border-box;flex-direction:column;flex:1;gap:16px;min-width:0;padding:0 12px;display:flex}.settings-window-loading__line,.settings-window-loading__card{background:var(--wb-color-fill-secondary,#0000000e);border-radius:var(--wb-radius-2,6px);animation:1.2s ease-in-out infinite alternate settings-window-loading-pulse}.settings-window-loading__line{width:88%;height:26px}.settings-window-loading__line:first-child{width:42%;height:26px;margin-bottom:8px}.settings-window-loading__nav .settings-window-loading__line:first-child{width:42%;height:26px;margin-bottom:10px}.settings-window-loading__card{width:100%;height:72px}@keyframes settings-window-loading-pulse{0%{opacity:.55}to{opacity:1}}@media (prefers-reduced-motion:reduce){.settings-window-loading__line,.settings-window-loading__card{animation:none}}[data-theme=dark] .settings-window-chrome,body.vscode-dark .settings-window-chrome,.vscode-dark .settings-window-chrome,body.dark .settings-window-chrome,.dark .settings-window-chrome{background:var(--wb-bg-modal)}.settings-window-subpage-header{-webkit-app-region:no-drag;align-items:center;gap:var(--wb-spacing-2,4px);padding:var(--wb-spacing-3,8px) var(--wb-spacing-5,16px);border-bottom:1px solid var(--wb-border-subtle,#00000014);flex:none;display:flex}.settings-window-subpage-header__back.wb-button--small .wb-button__icon{width:var(--wb-icon-size-lg);height:var(--wb-icon-size-lg)}.settings-window-subpage-header__title{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--wb-color-text-primary,#1f2329);font-size:var(--wb-font-body-size,14px);font-weight:var(--wb-font-weight-500,500);flex:1;overflow:hidden}.settings-window-subpage-header__extra{-webkit-app-region:no-drag;margin-left:auto}.settings-window-panel{color:var(--wb-color-text-secondary,#646a73);flex-direction:column;align-items:flex-start;gap:12px;max-width:520px;padding:24px;display:flex}.settings-window-panel h2{color:var(--wb-color-text-primary,#1f2329);margin:0;font-size:20px}.settings-window-panel p{margin:0;font-size:14px;line-height:1.6}[data-theme=dark] .settings-window-root{--wb-color-text-primary:#f2f3f5;--wb-color-text-secondary:#a6a8ad} +.settings-window-root,.settings-modal{--wb-motion-duration-fast:.15s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-bg-secondary:var(--wb-color-bg-secondary-hover-active);--wb-color-border-primary:var(--wb-palette-gray-5);--wb-radius-4xl:24px;--wb-motion-duration-base:.2s;--wb-motion-easing-emphasized:cubic-bezier(.2, 0, 0, 1);--wb-spacing-9:40px;--wb-spacing-4:12px;--wb-spacing-1:2px;--wb-scrollbar-thumb-bg:#0000001f;--wb-spacing-2:4px;--wb-spacing-3:8px;--wb-icon-size-lg:var(--wb-size-5);--wb-color-text-primary:var(--wb-palette-black-100);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-spacing-6:20px;--wb-radius-full:9999px;--wb-scrollbar-thumb:var(--wb-palette-black-20);--wb-scrollbar-thumb-hover:var(--wb-palette-black-30);--wb-switch-track-bg:var(--wb-palette-gray-5);--wb-switch-track-bg-checked:var(--wb-brand-primary);--wb-switch-track-border-color-checked:var(--wb-brand-primary);--wb-switch-thumb-translate:8px;--wb-switch-track-bg-danger:var(--wb-palette-red-9);--wb-switch-track-border-color-danger:var(--wb-palette-red-9);--wb-switch-thumb-translate-small:8px;--wb-switch-thumb-translate-large:12px;--wb-switch-track-bg-disabled:var(--wb-color-bg-primary-default);--wb-switch-track-bg-checked-disabled:var(--wb-color-text-brand-disabled);--wb-spacing-5:16px;--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-bg-modal:var(--wb-palette-white-100);--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-font-caption-size:var(--wb-font-size-3);--wb-font-caption-line-height:var(--wb-font-line-height-4-2);--wb-radius-lg:8px;--wb-font-body-size:var(--wb-font-size-5);--wb-font-body-line-height:var(--wb-font-line-height-5-2);--wb-todo-menu-bg-hover:var(--wb-palette-gray-4);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-dropdown-item-bg-hover:var(--wb-color-palette-gray-3,#f2f2f2);--wb-icon-size-md:var(--wb-size-4);--wb-border-subtle:var(--wb-palette-gray-3);--wb-font-weight-500:500;--wb-color-bg-secondary-hover-active:var(--wb-palette-gray-2);--wb-palette-gray-5:#e6e6e6;--wb-size-5:20px;--wb-palette-black-100:#000;--wb-palette-black-70:#000000b3;--wb-palette-black-20:#0003;--wb-palette-black-30:#0000004d;--wb-brand-primary:var(--wb-palette-brand-8);--wb-palette-red-9:#b42c3f;--wb-color-text-brand-disabled:var(--wb-palette-brand-3);--wb-palette-gray-3:#f2f2f2;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-font-size-3:12px;--wb-font-line-height-4-2:18px;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-gray-4:#ebebeb;--wb-size-4:16px;--wb-palette-gray-2:#f7f7f7;--wb-palette-brand-8:#00c29a;--wb-palette-brand-3:#9fe8d9} +:root[data-theme=dark] .settings-window-root,:root[data-theme=dark] .settings-modal{--wb-bg-secondary:var(--wb-palette-gray-2);--wb-color-border-primary:#262626;--wb-scrollbar-thumb-bg:#ffffff1f;--wb-color-text-primary:#fff;--wb-color-text-secondary:#ffffffa6;--wb-scrollbar-thumb:var(--wb-palette-white-20);--wb-scrollbar-thumb-hover:var(--wb-palette-white-30);--wb-switch-track-bg:var(--wb-bg-elevated);--wb-switch-track-bg-checked:var(--wb-brand-primary);--wb-switch-track-border-color-checked:var(--wb-brand-primary);--wb-switch-track-bg-danger:var(--wb-palette-red-9);--wb-switch-track-border-color-danger:var(--wb-palette-red-9);--wb-switch-track-bg-disabled:var(--wb-palette-gray-5);--wb-switch-track-bg-checked-disabled:var(--wb-palette-brand-4);--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-bg-modal:var(--wb-palette-gray-1);--wb-color-text-tertiary:#ffffff80;--wb-todo-menu-bg-hover:color-mix(in srgb, var(--wb-palette-white-100) 8%, transparent);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-dropdown-item-bg-hover:#2e2e2e;--wb-border-subtle:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-color-bg-secondary-hover-active:var(--wb-palette-gray-2);--wb-palette-gray-5:#3a3a3a;--wb-palette-black-100:#000;--wb-palette-black-70:#000000b3;--wb-palette-black-20:#0003;--wb-palette-black-30:#0000004d;--wb-brand-primary:var(--wb-palette-brand-7);--wb-palette-red-9:#dc4258;--wb-color-text-brand-disabled:#3dd9b659;--wb-palette-gray-3:#1f1f1f;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-palette-gray-4:#242424;--wb-palette-gray-2:#292929;--wb-palette-brand-8:#4cf0ce;--wb-palette-brand-3:#185f51} diff --git a/src/renderer/src/components/source-appearance-panel-recovered.css b/src/renderer/src/components/source-appearance-panel-recovered.css new file mode 100644 index 0000000..729850b --- /dev/null +++ b/src/renderer/src/components/source-appearance-panel-recovered.css @@ -0,0 +1,4 @@ +/* REA ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99; appearance-DMmhwsxq.css sha256=af58cb3134d3e6c474424f6d718daa8f5be0c6e29d40aa5f9f80c14b3ffbbbd2; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696. */ +.appearance-panel{box-sizing:border-box;height:calc(100% + 20px);color:var(--wb-text-strong);flex-direction:column;margin-bottom:-20px;display:flex;overflow:hidden}.appearance-panel--loading,.appearance-panel--empty{color:var(--wb-text-medium);justify-content:center;align-items:center;font-size:13px}.appearance-panel__body{border-top-left-radius:12px;border-top-right-radius:12px;flex-direction:column;flex:1;min-height:0;margin-left:4px;display:flex}.appearance-layout{flex-direction:column;flex:1;min-height:0;display:flex}.appearance-preview{aspect-ratio:2;background:linear-gradient(to bottom, var(--wb-todo-menu-bg-active), color-mix(in srgb, var(--wb-todo-menu-bg-active) 70%, transparent));border-radius:12px;flex-shrink:0;justify-content:center;width:100%;max-height:72%;padding-top:7.3%;display:flex;position:relative;overflow:hidden}.appearance-preview__image{object-fit:cover;object-position:top;border-radius:8px 8px 0 0;width:77%;box-shadow:0 4px 16px #0000001f}.appearance-preview__image--empty{background:var(--wb-bg-primary)}.appearance-preview__loading{background:color-mix(in srgb, var(--wb-bg-primary) 35%, transparent);cursor:progress;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.appearance-list__header{flex-shrink:0;align-items:baseline;gap:8px;padding-top:24px;padding-bottom:12px;display:flex}.appearance-list__title{color:var(--wb-text-strong);font-size:14px;font-weight:600}.appearance-list__desc{color:var(--wb-text-medium);font-size:12px}.appearance-list{scrollbar-width:thin;scrollbar-color:var(--wb-border-default) transparent;flex:1;min-height:0;overflow-y:auto}.appearance-list::-webkit-scrollbar{width:4px}.appearance-list::-webkit-scrollbar-track{background:0 0}.appearance-list::-webkit-scrollbar-thumb{background:var(--wb-border-default);border-radius:2px}.appearance-panel__load-error{border:1px solid var(--wb-border-default);background:var(--wb-bg-card);color:var(--wb-text-medium);border-radius:8px;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;font-size:13px;display:flex}.appearance-card-grid{grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:12px 8px;padding:4px 4px 0;display:grid}.appearance-card-cell{flex-direction:column;gap:4px;min-width:110px;max-width:166px;display:flex}.appearance-card{aspect-ratio:3/2;cursor:pointer;box-sizing:border-box;background-position:50%;background-size:cover;border:none;border-radius:10px;width:100%;padding:0;transition:outline-color .15s,box-shadow .15s;position:relative;overflow:hidden}.appearance-card--light{background-color:#ebebeb;box-shadow:inset 0 0 0 1px #e6e8ed}.appearance-card--dark{background-color:#000000bf}.appearance-card--trial{cursor:pointer}.appearance-card:not(.appearance-card--selected):hover{box-shadow:0 2px 8px #0000001f}.appearance-card--light:not(.appearance-card--selected):hover{box-shadow:inset 0 0 0 1px #e6e8ed,0 2px 8px #0000001f}.appearance-card--selected{outline:2px solid var(--wb-color-text-solid,#000);outline-offset:2px}.appearance-card--coming-soon{cursor:default;outline-offset:-1px;background-color:#ebebeb;justify-content:center;align-items:center;display:flex}.appearance-card--coming-soon:hover{box-shadow:none}.appearance-card__name{color:var(--wb-text-strong);text-align:center;font-size:12px;font-weight:400;line-height:18px}.appearance-card--selected~.appearance-card__name{font-weight:600}.appearance-card__coming-soon-text{color:#0000004d;font-size:12px}[data-theme=dark] .appearance-card--selected{outline-color:var(--wb-color-text-on-bg-default,#fff)}.appearance-badge{border-radius:100px;align-items:center;height:18px;padding:0 8px;font-family:PingFang SC,-apple-system,BlinkMacSystemFont,sans-serif;font-size:10px;font-weight:500;line-height:18px;display:inline-flex}.appearance-badge--free,.appearance-badge--pro{color:#000000b3;background:#fffc}.appearance-badge--advanced{color:#fff;background:#0009}.appearance-badge--ultimate{color:#ffd989;background:#000000b3}.appearance-badge--validity{color:#fff;background:#0006;height:20px;font-weight:400}.appearance-preview .appearance-badge--validity{position:absolute;top:8px;right:8px}.appearance-card .appearance-badge{position:absolute;top:6px;right:6px}.appearance-upgrade-bar{background:var(--wb-bg-card);border-radius:12px 12px 0 0;flex-direction:column;flex-shrink:0;gap:4px;padding:24px;animation:.2s appearance-upgrade-bar-in;display:flex;box-shadow:0 -4px 32px #00000014}.appearance-upgrade-bar__head{align-items:center;gap:8px;display:flex}.appearance-upgrade-bar__title{color:var(--wb-text-strong);font-size:16px;font-weight:600;line-height:21px}.appearance-upgrade-bar__actions{justify-content:space-between;align-items:center;gap:12px;display:flex}.appearance-upgrade-bar__desc{color:var(--wb-text-weak);font-size:14px;line-height:22px}.appearance-upgrade-bar__btn{border-radius:8px;flex-shrink:0;height:32px;padding:0 16px;font-size:14px;font-weight:600} +.appearance-panel{--wb-text-strong:var(--wb-color-text-primary);--wb-text-medium:var(--wb-color-text-secondary);--wb-todo-menu-bg-active:var(--wb-palette-gray-5);--wb-bg-primary:var(--wb-palette-white-100);--wb-border-default:color-mix(in srgb, var(--wb-palette-black-100) 8%, transparent);--wb-bg-card:var(--wb-palette-white-100);--wb-color-text-solid:var(--wb-palette-black-100);--wb-text-weak:var(--wb-color-text-tertiary);--wb-color-text-primary:var(--wb-palette-black-100);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-palette-gray-5:#e6e6e6;--wb-palette-white-100:#fff;--wb-palette-black-100:#000;--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-palette-black-70:#000000b3;--wb-palette-black-50:#00000080} +:root[data-theme=dark] .appearance-panel{--wb-text-strong:var(--wb-color-text-primary);--wb-text-medium:var(--wb-color-text-secondary);--wb-todo-menu-bg-active:color-mix(in srgb, var(--wb-palette-white-100) 12%, transparent);--wb-bg-primary:var(--wb-palette-gray-3);--wb-border-default:color-mix(in srgb, var(--wb-palette-white-100) 14%, transparent);--wb-bg-card:#242424;--wb-color-text-solid:var(--wb-palette-white-100);--wb-text-weak:var(--wb-color-text-tertiary);--wb-color-text-primary:#fff;--wb-color-text-secondary:#ffffffa6;--wb-palette-gray-5:#3a3a3a;--wb-palette-white-100:#fff;--wb-palette-black-100:#000;--wb-color-text-tertiary:#ffffff80;--wb-palette-black-70:#000000b3;--wb-palette-black-50:#00000080} diff --git a/src/renderer/src/components/source-appearance-panel.css b/src/renderer/src/components/source-appearance-panel.css new file mode 100644 index 0000000..29a5f9e --- /dev/null +++ b/src/renderer/src/components/source-appearance-panel.css @@ -0,0 +1,19 @@ +@import './source-appearance-panel-recovered.css'; + +/* + * 本地适配(与 REA 回收的原版规则一一对应): + * + * 1. 原版深色选择器是 html 上的 `.dark` / `[data-cb-theme=dark]`(REA 证据里 html.class 从 + * `light cb-light vscode-light` 变为 `dark cb-dark vscode-dark`),YCode 用 `[data-theme=dark]`, + * recover-appearance-panel.mjs 已在投影时改写。 + * 2. 原版把整套 `--wb-*` 设计变量放在全局 :root,YCode 按既有约定收敛到面板作用域(见恢复脚本)。 + * 3. 原版外观页只随主题切换换预览图;YCode 只提供 浅色/深色 两张卡,网格仍用原版的 + * repeat(auto-fill, minmax(110px, 1fr)),因此两张卡与首屏位置一致。 + * 4. 原版 `.appearance-panel{height:calc(100% + 20px);margin-bottom:-20px}` 是刻意的: + * 它抵消 `.settings-modal__panel` 的 20px 底部内边距,让预览卡贴到窗口底边(原版实测 + * 面板高 640 = 内容盒 620 + 20)。这里保持原值,不做本地覆盖。 + * 5. 恢复的 appearance-DMmhwsxq.css 只给 `.appearance-list__title` 设了字号/字重,没有行高; + * 原版该标题的行盒是 20px(标题 bounds 20 高,表头 24+20+12 = 56 高,见 REA ev_dee8f184…)。 + * YCode 继承的行高是 22px(会把表头撑成 58),因此按原版实测补一行 20px 行高。 + */ +.appearance-list__title { line-height: 20px }