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:
1 parent
7b68d9eb25
commit
b36b52831b
25 files changed
+727
-21
No files matched your search
@@ -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. 快捷键页面与真实功能
|
||||
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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']])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ModelSettingsPanel from './ModelSettingsPanel'
|
||||
import GeneralSettingsPanel from './GeneralSettingsPanel'
|
||||
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
|
||||
import SourceSettingsNavigation from './SourceSettingsNavigation'
|
||||
import { useSharedTheme } from '../useSharedTheme'
|
||||
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
|
||||
@@ -59,7 +60,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
||||
{section === 'settings' && <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 === '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>}
|
||||
</div></div>
|
||||
</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-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 }
|
||||
Reference in new issue
Block a user