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
@@ -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`)
|
||||
Reference in new issue
Block a user