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 ( +
{settings.appInfo?.version}