feat(settings): replicate the WorkBuddy appearance panel and dark theme (TODO 11)

Recover the WorkBuddy 5.8.3 appearance page from REA evidence (appearance-DMmhwsxq.css + tokens-Df-i-zsO.css): 44 recovered .appearance-* rules, scoped light/dark tokens, original geometry (panel [232,40,638,640], preview 634x317, grid 634x218.391, cards 118.797x79.188) and the two REA-exported preview images with provenance sha256.

Wire the panel into SettingsDialog with only the requested light/dark cards, add scoped tokens to the settings shell so the dark theme no longer renders a white window with near-white text, and verify with scripts/verify-appearance-settings.cjs (21 assertions: geometry, instant switch, cross-window sync, persistence, dark/light surface sampling with contrast >= 4.5, no renderer errors). Update the legacy nav/account/desktop appearance verifiers to the new .appearance-card selectors.
This commit is contained in:
xiaohan.yuan committed 2026-10-17 09:27:59 +08:00
1 parent 7b68d9eb25
commit b36b52831b
25 files changed
+727 -21

No files matched your search

+30 -5
View File
@@ -338,12 +338,37 @@
### 11. 外观与浅色/深色主题 ### 11. 外观与浅色/深色主题
- [ ] 对齐原版外观页面及主题切换交互;当前只保留浅色、深色两种选择。 - [x] 对齐原版外观页面及主题切换交互;当前只保留浅色、深色两种选择。
- [ ] 梳理原版主题切换会改变的 token、样式、资源和组件状态。 - [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. 快捷键页面与真实功能 ### 12. 快捷键页面与真实功能
+17
View File
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
@@ -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 }
]
}
+80
View File
@@ -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')
+42 -2
View File
@@ -11,6 +11,9 @@ const windowCss = module.source.artifact.text
if (sha(windowCss) !== '8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986') throw new Error('Window CSS changed') if (sha(windowCss) !== '8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986') throw new Error('Window CSS changed')
const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8') const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8')
if (sha(contexts) !== '50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380') throw new Error('Contexts CSS changed') 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) { function project(node) {
if (node.type === 'rule') { if (node.type === 'rule') {
const selectors = node.selectors.filter(selector => /\.settings-(modal|navigation)/.test(selector)) const selectors = node.selectors.filter(selector => /\.settings-(modal|navigation)/.test(selector))
@@ -23,5 +26,42 @@ function project(node) {
return null return null
} }
const root = postcss.root({ nodes: postcss.parse(contexts).nodes.map(project).filter(Boolean) }) 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`) // 原版把 `--wb-*` 设计变量放在全局 :root(tokens 样式表);YCode 没有全局 token 表,按组件作用域复制。
console.log('Recovered settings shell CSS with original media conditions') // 设置窗口的根元素是 `.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`)
+7 -7
View File
@@ -237,12 +237,12 @@ if (!process.versions.electron) {
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click') await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click')
await openSettings('appearance') await openSettings('appearance')
observe(settings) observe(settings)
await wait('!!document.querySelector(".segmented")', 'appearance panel', settings) await wait('!!document.querySelector(".appearance-panel")', 'appearance panel', settings)
check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>node.textContent)`, 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('.segmented__item')].map(node=>[node.textContent,node.dataset.active])`, settings), [['浅色', 'true'], ['深色', 'false']]) 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 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') 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']) check('appearance row drives the shared theme', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['dark', 'dark'])
await read(openMenu) 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(".ycode-account-menu")', 'menu closed after settings click')
await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings) 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 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 step('menu-light-restore')
await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings) await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings)
await wait('!!document.querySelector(".segmented")', 'appearance panel again', settings) await wait('!!document.querySelector(".appearance-panel")', 'appearance panel again', settings)
await clickText('.segmented__item', '浅色', settings) await mouseClick('[aria-label="浅色"].appearance-card', settings)
await wait(`document.documentElement.dataset.theme==='light'`, 'main window receives light theme') 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']) 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 const settingsId = settings.id
+280
View File
@@ -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)
})
}
+3 -3
View File
@@ -92,7 +92,7 @@ if (!process.versions.electron) {
await step('ui-dark-theme-and-reused-settings-navigation') await step('ui-dark-theme-and-reused-settings-navigation')
await openSettings('appearance'); observe(settings) await openSettings('appearance'); observe(settings)
await click('[data-settings-tab="appearance"]', 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') await wait('document.documentElement.dataset.theme==="dark"', 'main receives dark appearance')
let theme = await themeSnapshot('dark-selected-in-native-settings') 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']]) 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 const previous = settings.id
await openSettings('model'); await wait('!!document.querySelector(".models-settings-panel")', 'reused settings navigates to actual models panel', settings) 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('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) await click('.settings-window-close', settings)
for (let i = 0; i < 150 && !settings.isDestroyed(); i++) await pause(20) for (let i = 0; i < 150 && !settings.isDestroyed(); i++) await pause(20)
check('settings close destroys actual native window', settings.isDestroyed(), true) 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) check('reopened settings uses a fresh native window', settings.id !== previous, true)
theme = await themeSnapshot('reopened-settings-persists-dark') theme = await themeSnapshot('reopened-settings-persists-dark')
check('native settings reopen preserves shared dark appearance', [theme.main, theme.settings], [['dark', 'dark'], ['dark', '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') await wait('document.documentElement.dataset.theme==="light"', 'main receives light appearance')
theme = await themeSnapshot('light-restored-in-native-settings') 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']]) check('native UI light restores both renderers and storage', [theme.main, theme.settings], [['light', 'light'], ['light', 'light']])
+2 -2
View File
@@ -144,8 +144,8 @@ if (!process.versions.electron) {
await step('settings-navigation-still-works') await step('settings-navigation-still-works')
await read(`(()=>{const node=document.querySelector('[data-settings-tab="appearance"]');node.click();return true})()`, settings) await read(`(()=>{const node=document.querySelector('[data-settings-tab="appearance"]');node.click();return true})()`, settings)
await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings) await wait(`!!document.querySelector('.appearance-panel')`, 'appearance panel', settings)
check('appearance entry still opens its panel', await read(`!!document.querySelector('.segmented')&&!document.querySelector('.models-settings-panel')`, settings), true) 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 read(`(()=>{const node=document.querySelector('[data-settings-tab="models"]');node.click();return true})()`, settings)
await wait(`!!document.querySelector('.models-settings-panel')`, 'models panel', 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) check('models entry still opens its panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true)
@@ -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 (
<div className="appearance-panel">
<div className="appearance-panel__body">
<div className="appearance-layout">
<div className="appearance-preview">
{/* 原版 IMG 的 alt 为「效果预览」(REA ev_c0b1dacb… / ev_43c82492… 的 selected_node.attributes.alt)。 */}
<img className="appearance-preview__image" src={theme === 'dark' ? darkPreview : lightPreview} alt="效果预览" />
<span className="appearance-badge appearance-badge--validity">永久有效</span>
</div>
<div className="appearance-list__header">
<span className="appearance-list__title">全部主题</span>
</div>
<div className="appearance-list">
<div className="appearance-card-grid">
{THEMES.map((item) => (
<div className="appearance-card-cell" key={item.value}>
<button type="button" aria-label={item.label} aria-pressed={theme === item.value}
className={`appearance-card appearance-card--${item.value}${theme === item.value ? ' appearance-card--selected' : ''}`}
onClick={() => onThemeChange(item.value)} />
<span className="appearance-card__name">{item.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import ModelSettingsPanel from './ModelSettingsPanel' import ModelSettingsPanel from './ModelSettingsPanel'
import GeneralSettingsPanel from './GeneralSettingsPanel' import GeneralSettingsPanel from './GeneralSettingsPanel'
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
import SourceSettingsNavigation from './SourceSettingsNavigation' import SourceSettingsNavigation from './SourceSettingsNavigation'
import { useSharedTheme } from '../useSharedTheme' import { useSharedTheme } from '../useSharedTheme'
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation' import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
@@ -59,7 +60,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
{section === 'settings' && <GeneralSettingsPanel />} {section === 'settings' && <GeneralSettingsPanel />}
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />} {section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>} {section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
{section === 'appearance' && <div className="settings-window-panel"><h2>外观</h2><div className="segmented">{(['light', 'dark'] as const).map((value) => <button key={value} className="segmented__item" data-active={theme === value} onClick={() => onThemeChange(value)}>{value === 'light' ? '浅色' : '深色'}</button>)}</div></div>} {section === 'appearance' && <AppearanceSettingsPanel theme={theme} onThemeChange={onThemeChange} />}
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>} {section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
</div></div> </div></div>
</section> </section>
@@ -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": "效果预览"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -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-modLine truncated .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-modLine truncated
.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{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}
@@ -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}
@@ -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 }