feat(theme): 语义变量层基座 --ot-*(深色锁现值/浅色按 WCAG 派生)+ 表测试
This commit is contained in:
1 parent
0a7bdda2d3
commit
2136221f2c
4 files changed
+218
-39
No files matched your search
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
"pretest": "npm run typecheck",
|
"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",
|
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
|
||||||
"dist": "electron-vite build && electron-builder --win nsis",
|
"dist": "electron-vite build && electron-builder --win nsis",
|
||||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||||
|
|||||||
@@ -1,42 +1,8 @@
|
|||||||
import type { ThemeColors } from '@shared/theme'
|
import type { ThemeColors } from '@shared/theme'
|
||||||
|
import { contrast, isLightTheme, mix, semanticVars } from './semantic'
|
||||||
|
|
||||||
function parseHex(color: string): [number, number, number] | null {
|
// 兼容既有导入方(main.tsx 的 mix);新代码请直接从 ./semantic 导入。
|
||||||
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
|
export { contrast, luminance, mix } from './semantic'
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the active terminal theme has a light background. Tab bars derived
|
/** 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). */
|
* 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-bg', colors.background)
|
||||||
set('--chrome-fg', colors.foreground)
|
set('--chrome-fg', colors.foreground)
|
||||||
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
|
chromeIsLight = isLightTheme(colors.background)
|
||||||
if (chromeIsLight) {
|
if (chromeIsLight) {
|
||||||
// Light chrome: keep the bar close to the background (a big mix toward
|
// 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
|
// 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', tabLabel(colors.background, colors.foreground, 0.68))
|
||||||
set('--chrome-tab-fg-hover', tabLabel(colors.background, colors.foreground, 0.88))
|
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)
|
applyTabAccent(tabAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
* <html> 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<string, string> {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
Reference in new issue
Block a user