From 2136221f2c8d3a09b492dee87e8e5592e0200538 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 16 Oct 2026 20:21:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E8=AF=AD=E4=B9=89=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=B1=82=E5=9F=BA=E5=BA=A7=20--ot-*=EF=BC=88=E6=B7=B1?= =?UTF-8?q?=E8=89=B2=E9=94=81=E7=8E=B0=E5=80=BC/=E6=B5=85=E8=89=B2?= =?UTF-8?q?=E6=8C=89=20WCAG=20=E6=B4=BE=E7=94=9F=EF=BC=89+=20=E8=A1=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/renderer/src/theme/chrome.ts | 52 +++-------- src/renderer/src/theme/semantic.ts | 143 +++++++++++++++++++++++++++++ tests/semantic-theme.mjs | 60 ++++++++++++ 4 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/theme/semantic.ts create mode 100644 tests/semantic-theme.mjs diff --git a/package.json b/package.json index b4c5e6c..f0789fb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "pretest": "npm run typecheck", - "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs", + "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs", "predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only", "dist": "electron-vite build && electron-builder --win nsis", "dist:dir": "electron-vite build && electron-builder --win --dir" diff --git a/src/renderer/src/theme/chrome.ts b/src/renderer/src/theme/chrome.ts index f59554d..90271ed 100644 --- a/src/renderer/src/theme/chrome.ts +++ b/src/renderer/src/theme/chrome.ts @@ -1,42 +1,8 @@ import type { ThemeColors } from '@shared/theme' +import { contrast, isLightTheme, mix, semanticVars } from './semantic' -function parseHex(color: string): [number, number, number] | null { - const m = /^#([0-9a-f]{6})$/i.exec(color.trim()) - if (!m) return null - const n = parseInt(m[1], 16) - return [(n >> 16) & 255, (n >> 8) & 255, n & 255] -} - -/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */ -function luminance(color: string): number | null { - const c = parseHex(color) - if (!c) return null - const lin = c.map((v) => { - const s = v / 255 - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 - }) - return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] -} - -function toHex(v: number): string { - return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0') -} - -/** WCAG contrast ratio between two colors; 21 when either is not hex. */ -function contrast(a: string, b: string): number { - const la = luminance(a) - const lb = luminance(b) - if (la === null || lb === null) return 21 - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) -} - -/** t=0 → a, t=1 → b */ -export function mix(a: string, b: string, t: number): string | null { - const ca = parseHex(a) - const cb = parseHex(b) - if (!ca || !cb) return null - return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}` -} +// 兼容既有导入方(main.tsx 的 mix);新代码请直接从 ./semantic 导入。 +export { contrast, luminance, mix } from './semantic' /** Whether the active terminal theme has a light background. Tab bars derived * from it flip their overlay direction (white-on-dark vs black-on-light). */ @@ -65,7 +31,7 @@ export function applyChromeTheme(colors: ThemeColors): void { } set('--chrome-bg', colors.background) set('--chrome-fg', colors.foreground) - chromeIsLight = (luminance(colors.background) ?? 0) > 0.5 + chromeIsLight = isLightTheme(colors.background) if (chromeIsLight) { // Light chrome: keep the bar close to the background (a big mix toward // black turns pale themes into a muddy gray strip) and overlay tabs with @@ -90,6 +56,16 @@ export function applyChromeTheme(colors: ThemeColors): void { set('--chrome-tab-fg', tabLabel(colors.background, colors.foreground, 0.68)) set('--chrome-tab-fg-hover', tabLabel(colors.background, colors.foreground, 0.88)) } + // Semantic layer: --ot-* variables consumed by panels, popovers and inline + // styles. The dark branch locks the SEMANTIC anchors (zero visual change); + // the light branch darkens them until the WCAG ratio holds against this + // theme's background. `color-scheme` rides along so native scrollbars and + // form controls follow the same switch. + const vars = semanticVars(colors, chromeIsLight) + for (const [key, value] of Object.entries(vars)) { + if (key === 'colorScheme') style.setProperty('color-scheme', value) + else set(`--ot-${key}`, value) + } applyTabAccent(tabAccent) } diff --git a/src/renderer/src/theme/semantic.ts b/src/renderer/src/theme/semantic.ts new file mode 100644 index 0000000..49eb434 --- /dev/null +++ b/src/renderer/src/theme/semantic.ts @@ -0,0 +1,143 @@ +/** + * Semantic color layer for app chrome. PURE module: no imports (it is loaded + * directly by tests/semantic-theme.mjs via Node's native TS type stripping), + * no DOM access. chrome.ts turns semanticVars() into --ot-* CSS variables on + * whenever the terminal theme changes. + * + * Dark-branch values are LOCKED to the historical literals (zero visual + * regression); light-branch values are derived per-theme by darkening the + * same anchors until the WCAG ratio is met. + */ + +interface ThemeLike { + background: string + foreground: string +} + +function parseHex(color: string): [number, number, number] | null { + const m = /^#([0-9a-f]{6})$/i.exec(color.trim()) + if (!m) return null + const n = parseInt(m[1], 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */ +export function luminance(color: string): number | null { + const c = parseHex(color) + if (!c) return null + const lin = c.map((v) => { + const s = v / 255 + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] +} + +function toHex(v: number): string { + return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0') +} + +/** t=0 → a, t=1 → b */ +export function mix(a: string, b: string, t: number): string | null { + const ca = parseHex(a) + const cb = parseHex(b) + if (!ca || !cb) return null + return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}` +} + +/** WCAG contrast ratio; 21 when either side is not parseable hex. */ +export function contrast(a: string, b: string): number { + const la = luminance(a) + const lb = luminance(b) + if (la === null || lb === null) return 21 + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + +export function isLightTheme(background: string): boolean { + return (luminance(background) ?? 0) > 0.5 +} + +/** Darken `color` toward black in 0.05 steps until contrast vs `bg` >= ratio. + * Returns the input unchanged when it already passes (or never parses). */ +export function darkenToRatio(color: string, bg: string, ratio: number): string { + if (contrast(color, bg) >= ratio) return color + let t = 0 + let cur = color + while (t < 0.85) { + t = Math.round((t + 0.05) * 100) / 100 + const next = mix(color, '#000000', t) + if (!next) return color + cur = next + if (contrast(cur, bg) >= ratio) return cur + } + return cur +} + +/** Historical dark-chrome literals. The dark branch MUST keep these values. */ +export const SEMANTIC = { + muted: '#8a8a8a', + success: '#3fb950', + danger: '#f85149', + warning: '#d29922', + info: '#58a6ff', + surfaceRaised: 'rgba(24, 26, 31, 0.85)', + hover: 'rgba(255, 255, 255, 0.08)', + borderStrong: 'rgba(255, 255, 255, 0.16)', + scrollbar: 'rgba(125, 145, 170, 0.5)', + scrollbarActive: 'rgba(150, 172, 200, 0.7)', + shadow: 'rgba(0, 0, 0, 0.45)', + focusRing: 'rgba(59, 142, 234, 0.2)' +} as const + +/** Values for --ot-* variables (keys without the prefix). */ +export function semanticVars(colors: ThemeLike, light: boolean): Record { + const bg = colors.background + const fg = colors.foreground + if (!light) { + return { + text: fg, + textSecondary: mix(bg, fg, 0.78) ?? fg, + muted: SEMANTIC.muted, + surface: mix(bg, fg, 0.05) ?? bg, + surfaceRaised: SEMANTIC.surfaceRaised, + hover: SEMANTIC.hover, + border: mix(bg, fg, 0.16) ?? bg, + borderStrong: SEMANTIC.borderStrong, + success: SEMANTIC.success, + successFg: SEMANTIC.success, + danger: SEMANTIC.danger, + dangerFg: SEMANTIC.danger, + warning: SEMANTIC.warning, + warningFg: SEMANTIC.warning, + info: SEMANTIC.info, + infoFg: SEMANTIC.info, + scrollbar: SEMANTIC.scrollbar, + scrollbarActive: SEMANTIC.scrollbarActive, + shadow: SEMANTIC.shadow, + focusRing: SEMANTIC.focusRing, + colorScheme: 'dark' + } + } + return { + text: darkenToRatio(fg, bg, 7), + textSecondary: darkenToRatio(mix(bg, fg, 0.75) ?? fg, bg, 4.5), + muted: darkenToRatio(SEMANTIC.muted, bg, 4.5), + surface: mix(bg, fg, 0.04) ?? bg, + surfaceRaised: mix(bg, '#000000', 0.045) ?? bg, + hover: 'rgba(0, 0, 0, 0.06)', + border: mix(bg, '#000000', 0.14) ?? bg, + borderStrong: 'rgba(0, 0, 0, 0.22)', + success: darkenToRatio(SEMANTIC.success, bg, 3), + successFg: darkenToRatio(SEMANTIC.success, bg, 4.5), + danger: darkenToRatio(SEMANTIC.danger, bg, 3), + dangerFg: darkenToRatio(SEMANTIC.danger, bg, 4.5), + warning: darkenToRatio(SEMANTIC.warning, bg, 3), + warningFg: darkenToRatio(SEMANTIC.warning, bg, 4.5), + info: darkenToRatio(SEMANTIC.info, bg, 3), + infoFg: darkenToRatio(SEMANTIC.info, bg, 4.5), + scrollbar: 'rgba(0, 0, 0, 0.28)', + scrollbarActive: 'rgba(0, 0, 0, 0.45)', + shadow: 'rgba(0, 0, 0, 0.15)', + focusRing: SEMANTIC.focusRing, + colorScheme: 'light' + } +} diff --git a/tests/semantic-theme.mjs b/tests/semantic-theme.mjs new file mode 100644 index 0000000..5a46e2b --- /dev/null +++ b/tests/semantic-theme.mjs @@ -0,0 +1,60 @@ +/** + * semantic.ts self-test. 该模块必须零 import(Node 原生类型剥离直连 .ts, + * 同 tests/terminal-cwd.mjs 的约束)。锁三件事: + * 1) isLightTheme 边界(>0.5 亮度为浅); + * 2) 深色分支 = SEMANTIC 锚点原值(零回归锁定); + * 3) 浅色分支全部达标:正文/fg ≥4.5,语义基色 ≥3.0。 + * Run: node tests/semantic-theme.mjs (must exit 0) + */ +import { isLightTheme, semanticVars, SEMANTIC } from '../src/renderer/src/theme/semantic.ts' + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} + +console.log('isLightTheme:') +ok(isLightTheme('#ffffff') === true, '#ffffff is light') +ok(isLightTheme('#fafafa') === true, '#fafafa is light') +ok(isLightTheme('#fdf6e3') === true, '#fdf6e3 is light') +ok(isLightTheme('#1e1e1e') === false, '#1e1e1e is dark') +ok(isLightTheme('#282a36') === false, '#282a36 is dark') + +console.log('dark branch locks anchors (zero regression):') +const dark = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false) +ok(dark['muted'] === SEMANTIC.muted, 'muted = anchor') +ok(dark['success'] === SEMANTIC.success, 'success = anchor') +ok(dark['danger'] === SEMANTIC.danger, 'danger = anchor') +ok(dark['warning'] === SEMANTIC.warning, 'warning = anchor') +ok(dark['info'] === SEMANTIC.info, 'info = anchor') +ok(dark['scrollbar'] === SEMANTIC.scrollbar, 'scrollbar = anchor') +ok(dark['shadow'] === SEMANTIC.shadow, 'shadow = anchor') +ok(dark['colorScheme'] === 'dark', 'colorScheme dark') + +console.log('light branch meets ratios:') +for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) { + const v = semanticVars({ background: bg, foreground: '#383a42' }, true) + const L = (h) => { + const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) + .map((s) => (s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4)) + return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] + } + const cr = (a, b) => { + const x = L(a), y = L(b) + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) + } + for (const key of ['muted', 'textSecondary', 'successFg', 'dangerFg', 'warningFg', 'infoFg']) { + ok(cr(v[key], bg) >= 4.5, `${bg} ${key} ${v[key]} >= 4.5 (${cr(v[key], bg).toFixed(2)})`) + } + for (const key of ['success', 'danger', 'warning', 'info']) { + ok(cr(v[key], bg) >= 3, `${bg} ${key} ${v[key]} >= 3.0 (${cr(v[key], bg).toFixed(2)})`) + } + ok(v['colorScheme'] === 'light', `${bg} colorScheme light`) +} + +if (failed > 0) { + console.error(`${failed} check(s) failed`) + process.exit(1) +} +console.log('semantic-theme: all ok')