diff --git a/package.json b/package.json index f0789fb..1712d29 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 && node tests/semantic-theme.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 && node tests/theme-contrast.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/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx index 04d9f27..61329c2 100644 --- a/src/renderer/src/ErrorBoundary.tsx +++ b/src/renderer/src/ErrorBoundary.tsx @@ -56,7 +56,7 @@ class ErrorBoundaryClass extends Component
{tFor(this.props.language, 'workspace.error.title')}
@@ -66,7 +66,7 @@ class ErrorBoundaryClass extends Component diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 949bfeb..05d8801 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -10,6 +10,7 @@ import { ErrorBoundary } from './ErrorBoundary' import ZmodemOffers from './workspace/ZmodemOffers' import { useSettingsStore } from './settings/store' import { mix } from './theme/chrome' +import { isLightTheme } from './theme/semantic' import './global.css' // Diagnostics: report renderer main-thread freezes after recovery. console.error @@ -221,14 +222,16 @@ function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX. const themeId = useSettingsStore((s) => s.settings.terminal.themeId) const customThemes = useSettingsStore((s) => s.settings.customThemes) const colors = getThemeById(themeId, customThemes).colors + const light = isLightTheme(colors.background) return ( { + const themeColors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors + const lightTheme = isLightTheme(themeColors.background) const term = new Terminal({ fontSize: fontOpts.fontSize, fontFamily: fontOpts.fontFamily, @@ -857,16 +860,21 @@ export function TerminalView({ cursorBlink: fontOpts.cursorBlink, cursorStyle: fontOpts.cursorStyle, cursorInactiveStyle: fontOpts.cursorInactiveStyle, - theme: withChromeColors(getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme, bgOpts.image !== ''), + theme: withChromeColors(themeColors as ITheme, bgOpts.image !== ''), allowProposedApi: true, allowTransparency: true, overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false }, - drawBoldTextInBrightColors: true, + // Light themes ship WCAG-compliant palettes, but apps still emit + // dark-tuned bright/bold colors; bold must NOT jump to the bright set. + drawBoldTextInBrightColors: !lightTheme, // In image mode the xterm background is transparent (#00000000), which // xterm's contrast cache treats as black; a raised contrast floor lifts // dark foreground colors (light themes) enough to stay readable over - // the wallpaper. Without an image keep 1 (off) to honor theme colors. - minimumContrastRatio: bgOpts.image ? 4.5 : 1, + // the wallpaper. Without an image light themes keep the 4.5 floor to + // rescue low-contrast app output; dark themes keep 1 (off) to honor + // theme colors. (Spelled minimumContrastRatio — minContrastRatio is + // silently ignored.) + minimumContrastRatio: bgOpts.image || lightTheme ? 4.5 : 1, automaticFontFallback: true, convertEol: false, windowsPty: { backend: 'conpty', buildNumber: 0 } @@ -1261,11 +1269,11 @@ export function TerminalView({ useEffect(() => { const term = termRef.current if (!term) return - term.options.theme = withChromeColors( - getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme, - bgOpts.image !== '' - ) - term.options.minimumContrastRatio = bgOpts.image ? 4.5 : 1 + const colors = getThemeById(themeOpts.themeId, themeOpts.customThemes).colors + const lightTheme = isLightTheme(colors.background) + term.options.theme = withChromeColors(colors as ITheme, bgOpts.image !== '') + term.options.drawBoldTextInBrightColors = !lightTheme + term.options.minimumContrastRatio = bgOpts.image || lightTheme ? 4.5 : 1 }, [themeOpts, bgOpts]) // ---- renderer mode changes: keep the instance, swap renderer, refit ---- diff --git a/src/shared/theme.ts b/src/shared/theme.ts index 33bb0a3..87c0db0 100644 --- a/src/shared/theme.ts +++ b/src/shared/theme.ts @@ -133,21 +133,21 @@ export const BUILTIN_THEMES: TerminalTheme[] = [ cursorAccent: '#fafafa', selectionBackground: '#d2d6db', black: '#383a42', - red: '#e45649', - green: '#50a14f', - yellow: '#c18401', - blue: '#0184bc', + red: '#c2493e', + green: '#40813f', + yellow: '#9a6a01', + blue: '#0177a9', magenta: '#a626a4', - cyan: '#0997b3', - white: '#fafafa', + cyan: '#07798f', + white: '#717171', brightBlack: '#4f525e', - brightRed: '#e06c75', - brightGreen: '#98c379', - brightYellow: '#e5c07b', - brightBlue: '#61afef', - brightMagenta: '#c678dd', - brightCyan: '#56b6c2', - brightWhite: '#ffffff' + brightRed: '#b3565e', + brightGreen: '#5b7549', + brightYellow: '#7e6a44', + brightBlue: '#3f729b', + brightMagenta: '#955aa6', + brightCyan: '#38767e', + brightWhite: '#737373' } }, { @@ -184,26 +184,26 @@ export const BUILTIN_THEMES: TerminalTheme[] = [ builtin: true, colors: { background: '#fdf6e3', - foreground: '#657b83', + foreground: '#586e75', cursor: '#586e75', cursorAccent: '#fdf6e3', selectionBackground: '#eee8d5', black: '#073642', - red: '#dc322f', - green: '#859900', - yellow: '#b58900', - blue: '#268bd2', - magenta: '#d33682', - cyan: '#2aa198', - white: '#eee8d5', - brightBlack: '#93a1a1', - brightRed: '#cb4b16', + red: '#d1302d', + green: '#647300', + yellow: '#886700', + blue: '#2076b3', + magenta: '#c8337c', + cyan: '#207972', + white: '#6b6860', + brightBlack: '#677171', + brightRed: '#c14715', brightGreen: '#586e75', - brightYellow: '#657b83', - brightBlue: '#839496', - brightMagenta: '#6c71c4', - brightCyan: '#93a1a1', - brightWhite: '#fdf6e3' + brightYellow: '#5b6f76', + brightBlue: '#626f71', + brightMagenta: '#6166b0', + brightCyan: '#677171', + brightWhite: '#726f66' } }, { @@ -295,27 +295,27 @@ export const BUILTIN_THEMES: TerminalTheme[] = [ name: 'GitHub Light', builtin: true, colors: { - background: '#ffffff', + background: '#f6f8fa', foreground: '#4d4d4c', cursor: '#4d4d4c', - cursorAccent: '#ffffff', + cursorAccent: '#f6f8fa', selectionBackground: '#dcdcdc', black: '#3e3e3e', red: '#c91b00', - green: '#00c200', - yellow: '#c7c400', + green: '#007e00', + yellow: '#777600', blue: '#0225c7', - magenta: '#c930c7', - cyan: '#00c5c7', - white: '#c7c7c7', + magenta: '#bf2ebd', + cyan: '#007677', + white: '#6d6d6d', brightBlack: '#676767', - brightRed: '#ff6d67', - brightGreen: '#5ff967', - brightYellow: '#ffff6c', - brightBlue: '#6871ff', - brightMagenta: '#ff76ff', - brightCyan: '#5ffdff', - brightWhite: '#ffffff' + brightRed: '#b34c48', + brightGreen: '#307d34', + brightYellow: '#737331', + brightBlue: '#5860d9', + brightMagenta: '#a64da6', + brightCyan: '#2b7273', + brightWhite: '#666666' } }, { diff --git a/tests/theme-contrast.mjs b/tests/theme-contrast.mjs new file mode 100644 index 0000000..10c1938 --- /dev/null +++ b/tests/theme-contrast.mjs @@ -0,0 +1,46 @@ +/** + * Builtin theme palette contrast gate. Light themes: foreground + all 16 ANSI + * colors must reach 4.5:1 against the theme background (WCAG AA body text); + * cursor must reach 3:1. Dark themes: only the foreground sanity check (>=4.5) + * — dark palettes are locked aesthetics, not regression targets here. + * Imports ../src/shared/theme.ts directly (zero-import module, Node >= 22.18). + * Run: node tests/theme-contrast.mjs (must exit 0) + */ +import { BUILTIN_THEMES } from '../src/shared/theme.ts' + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} +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) +} +const ANSI = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', + 'brightMagenta', 'brightCyan', 'brightWhite'] +const isLight = (bg) => L(bg) > 0.5 + +for (const t of BUILTIN_THEMES) { + const c = t.colors + const light = isLight(c.background) + console.log(`${t.id} (${light ? 'light' : 'dark'}, bg ${c.background}):`) + ok(cr(c.foreground, c.background) >= 4.5, `foreground ${c.foreground} ${cr(c.foreground, c.background).toFixed(2)}`) + ok(cr(c.cursor, c.background) >= 3, `cursor ${c.cursor} ${cr(c.cursor, c.background).toFixed(2)}`) + if (!light) continue + for (const k of ANSI) { + ok(cr(c[k], c.background) >= 4.5, `${k} ${c[k]} ${cr(c[k], c.background).toFixed(2)}`) + } +} + +if (failed > 0) { + console.error(`${failed} check(s) failed`) + process.exit(1) +} +console.log('theme-contrast: all ok')