From b42dcd9166d8038e973c7e9bb42701475d89f8ae Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Thu, 23 Jul 2026 00:27:10 -0700 Subject: [PATCH] fix: restore contrast correction for themes and CLI output, add high-contrast mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal text and app chrome have been hard to read since the theme system changed. Two independent causes: 1. xterm's minimumContrastRatio was 1 (correction fully disabled) in every dark theme, and only 4.5 in light. Removing the old `filter: invert(1) hue-rotate(180deg)` on `.light .xterm` took away the structural guarantee that Claude Code / Codex truecolor output stayed readable, and nothing replaced it for the 10 dark themes. 2. The muted text tokens fail WCAG AA against their own background in 7 of 12 themes (forge 2.90, night-owl 3.05, dusk 3.67, terracotta 3.95, ...). The theme-expansion PRs shipped without a contrast gate. Changes: - Raise --color-text-muted / -navigation-muted / -navigation-section / -interactive-muted to >=4.5:1 in dark, dusk, dusk-oled, forge, night-owl, night-owl-oled and terracotta. Hue preserved; lightness only. Applied at the semantic-token level, not the palette primitives, since --owl-400 and friends also feed --color-terminal-bright-black. - Add an opt-in "High contrast" toggle (Appearance settings) that pushes the same tokens to ~7:1 via a .high-contrast class. - Terminal minimumContrastRatio: 3 dark / 4.5 light by default, 9 when high contrast is on. 9 is deliberate — both xterm renderers halve the ratio for dim (SGR 2) cells, which is most of Claude Code's and Codex's secondary text, so 9 is what lands dim output at 4.5 AA. - Sync the pre-React bootstrap in index.html with THEME_CLASSES; it had drifted and was falling back to light-rounded for night-owl, night-owl-oled and terracotta, causing a light flash on every launch. - Give the remote PWA terminal a static minimumContrastRatio of 4.5. It has no AppConfig transport, so it is intentionally not wired to the setting. --color-text-disabled is deliberately left as-is; WCAG 1.4.3 exempts disabled controls and raising it destroys the disabled affordance. --- frontend/index.html | 16 +- .../src/components/panels/TerminalPanel.tsx | 25 ++- frontend/src/components/settings/catalog.tsx | 4 +- .../categories/AppearanceSettings.tsx | 14 +- frontend/src/contexts/ThemeContext.tsx | 24 ++- .../src/remote/hooks/useRemoteTerminal.ts | 3 + frontend/src/styles/tokens/colors.css | 156 ++++++++++++++---- frontend/src/types/config.ts | 4 + frontend/src/types/settings.ts | 1 + main/src/services/configManager.ts | 1 + main/src/types/config.ts | 4 + 11 files changed, 211 insertions(+), 41 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 7621a5da..a8a8d31e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,7 +11,7 @@
diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index ed8c4173..51c1c349 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -68,6 +68,22 @@ const MIN_VIABLE_RECT_PX = 100; // below this the container is hidden or mid-lay const MIN_PTY_COLS = 20; // mirrors main-process floor const MIN_PTY_ROWS = 5; const NEAR_BOTTOM_THRESHOLD_ROWS = 3; + +// xterm halves the configured ratio for dim (SGR 2) cells, so 9 is what gets dim +// CLI output (Claude Code / Codex) to 4.5:1 AA. Off-state stays a modest safety +// floor so the deliberate muted grays in the dark themes survive. +const HIGH_CONTRAST_MIN_RATIO = 9; // dim cells get ratio/2 = 4.5 (AA) +const LIGHT_MIN_RATIO = 4.5; +const DARK_MIN_RATIO = 3; + +// Takes highContrast as an argument rather than reading the `high-contrast` +// class: that class is stamped by ThemeProvider's effect, and React flushes +// passive effects child-first, so this component's effect would observe the +// previous value and leave the terminal one toggle behind. +const getMinimumContrastRatio = (highContrast: boolean): number => { + if (highContrast) return HIGH_CONTRAST_MIN_RATIO; + return document.documentElement.classList.contains('light') ? LIGHT_MIN_RATIO : DARK_MIN_RATIO; +}; const TERMINAL_VISIBILITY_VIEWER_ID = getTerminalVisibilityViewerId(); function getTerminalVisibilityViewerId(): string { @@ -298,7 +314,7 @@ export const TerminalPanel: React.FC = React.memo(({ panel, const sessionContext = useSession(); const sessionId = sessionContext?.sessionId; const workingDirectory = sessionContext?.workingDirectory; - const { theme } = useTheme(); + const { theme, highContrast } = useTheme(); if (sessionContext) { devLog.debug('[TerminalPanel] Session context:', sessionContext); @@ -826,7 +842,7 @@ export const TerminalPanel: React.FC = React.memo(({ panel, altClickMovesCursor: true, drawBoldTextInBrightColors: true, rescaleOverlappingGlyphs: true, - minimumContrastRatio: document.documentElement.classList.contains('light') ? 4.5 : 1, + minimumContrastRatio: getMinimumContrastRatio(highContrast), macOptionIsMeta: false, linkHandler: { activate: (_event, uri) => { @@ -1804,15 +1820,14 @@ export const TerminalPanel: React.FC = React.memo(({ panel, } const newTheme = getTerminalTheme(); xtermRef.current.options.theme = newTheme; - xtermRef.current.options.minimumContrastRatio = - document.documentElement.classList.contains('light') ? 4.5 : 1; + xtermRef.current.options.minimumContrastRatio = getMinimumContrastRatio(highContrast); const rows = xtermRef.current.rows; if (rows > 0) { xtermRef.current.refresh(0, rows - 1); // After refresh, restore scroll to bottom to prevent flicker-to-top xtermRef.current.scrollToBottom(); } - }, [theme]); + }, [theme, highContrast]); // Handle missing session context (show after all hooks have been called) diff --git a/frontend/src/components/settings/catalog.tsx b/frontend/src/components/settings/catalog.tsx index dc7cb29f..c50f86d9 100644 --- a/frontend/src/components/settings/catalog.tsx +++ b/frontend/src/components/settings/catalog.tsx @@ -38,8 +38,8 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategoryDefinition[] = [ label: 'Appearance', description: 'Theme, scale, and sidebar presentation.', icon: Monitor, - settingIds: ['theme', 'ui-scale', 'sidebar-pane-rows'], - aliases: ['theme', 'display', 'sidebar', 'zoom'], + settingIds: ['theme', 'high-contrast', 'ui-scale', 'sidebar-pane-rows'], + aliases: ['theme', 'display', 'sidebar', 'zoom', 'contrast', 'accessibility'], }, { id: 'terminal', diff --git a/frontend/src/components/settings/categories/AppearanceSettings.tsx b/frontend/src/components/settings/categories/AppearanceSettings.tsx index bef71234..8a9a0716 100644 --- a/frontend/src/components/settings/categories/AppearanceSettings.tsx +++ b/frontend/src/components/settings/categories/AppearanceSettings.tsx @@ -9,7 +9,7 @@ import { } from '../../ui/Select'; import { SettingsSection } from '../../ui/SettingsSection'; import { SettingRow, SettingsPage } from '../SettingRow'; -import { SegmentedControl } from '../SettingsControls'; +import { ImmediateToggle, SegmentedControl } from '../SettingsControls'; import type { SettingsPersistence } from '../useSettingsPersistence'; import type { AppConfig } from '../../../types/config'; @@ -55,6 +55,18 @@ export function AppearanceSettings({ persistence }: { persistence: SettingsPersi + + persistence.saveConfig('high-contrast', { highContrast: value })} + /> + VALID_THEMES.includes(t as Theme interface ThemeContextType { theme: Theme; setTheme: (theme: Theme) => void; + // Read-only here: the Appearance settings toggle writes it through + // persistence.saveConfig, and it flows back via the config sync effect. + highContrast: boolean; } const ThemeContext = createContext(undefined); @@ -36,6 +39,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre } return 'light-rounded'; }); + const [highContrast, setHighContrast] = useState(() => localStorage.getItem('high-contrast') === 'true'); // Sync theme from config when it loads useEffect(() => { @@ -45,6 +49,14 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }, [config?.theme]); + // Sync high contrast from config when it loads + useEffect(() => { + if (typeof config?.highContrast === 'boolean') { + setHighContrast(config.highContrast); + localStorage.setItem('high-contrast', String(config.highContrast)); + } + }, [config?.highContrast]); + useEffect(() => { const root = document.documentElement; const body = document.body; @@ -57,9 +69,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre root.classList.add(...themeClasses); body.classList.add(...themeClasses); + // High contrast is additive on top of the theme classes + root.classList.remove('high-contrast'); + body.classList.remove('high-contrast'); + if (highContrast) { + root.classList.add('high-contrast'); + body.classList.add('high-contrast'); + } + localStorage.setItem('theme', theme); - }, [theme]); + }, [theme, highContrast]); const updateTheme = (nextTheme: Theme) => { const previousTheme = theme; @@ -73,7 +93,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre }; return ( - + {children} ); diff --git a/frontend/src/remote/hooks/useRemoteTerminal.ts b/frontend/src/remote/hooks/useRemoteTerminal.ts index cd7e5bc8..c94a7ee4 100644 --- a/frontend/src/remote/hooks/useRemoteTerminal.ts +++ b/frontend/src/remote/hooks/useRemoteTerminal.ts @@ -63,6 +63,9 @@ export function useRemoteTerminal({ fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", monospace', fontSize: 13, scrollback: 10_000, + // Static AA floor: the remote PWA is browser-served with no AppConfig transport, + // so the desktop high-contrast setting cannot be plumbed here. + minimumContrastRatio: 4.5, theme: { background: '#010409', foreground: '#e6edf3', diff --git a/frontend/src/styles/tokens/colors.css b/frontend/src/styles/tokens/colors.css index 856ac280..18692991 100644 --- a/frontend/src/styles/tokens/colors.css +++ b/frontend/src/styles/tokens/colors.css @@ -185,7 +185,7 @@ --color-text-primary: rgb(230 237 243); /* GitHub #e6edf3 */ --color-text-secondary: rgb(200 208 217); /* Brighter secondary */ --color-text-tertiary: rgb(125 133 144); /* GitHub #7d8590 */ - --color-text-muted: rgb(110 118 129); /* GitHub #6e7681 */ + --color-text-muted: rgb(114 122 133); /* #727a85 — 4.7:1 on bg-primary (AA) */ --color-text-disabled: rgb(72 79 88); /* Semantic Tokens - Borders */ @@ -221,7 +221,7 @@ --color-interactive-border-hover: rgba(var(--color-interactive-rgb), 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: rgb(110 118 129); + --color-text-interactive-muted: rgb(114 122 133); /* #727a85 — 4.7:1 on bg-primary (AA) */ /* Focus ring variations */ --color-focus-ring-subtle: rgba(var(--color-interactive-rgb), 0.4); @@ -285,10 +285,10 @@ --color-text-navigation-primary: rgb(200 208 217); --color-text-navigation-secondary: rgb(125 133 144); - --color-text-navigation-muted: rgb(110 118 129); + --color-text-navigation-muted: rgb(114 122 133); /* #727a85 — 4.7:1 on bg-primary (AA) */ --color-text-navigation-selected: rgb(230 237 243); --color-text-navigation-hover: rgb(230 237 243); - --color-text-navigation-section: rgb(110 118 129); + --color-text-navigation-section: rgb(114 122 133); /* #727a85 — 4.7:1 on bg-primary (AA) */ --color-text-navigation-brand: rgb(125 133 144); --color-border-navigation: rgba(255, 255, 255, 0.06); @@ -341,7 +341,7 @@ --color-text-primary: rgb(31 35 40); /* GitHub #1f2328 */ --color-text-secondary: rgb(93 101 110); /* #5d656e — 5.3:1 on white */ --color-text-tertiary: rgb(101 109 118); /* #656d76 — 4.75:1 on white */ - --color-text-muted: rgb(107 114 128); /* gray-500 — 4.6:1 on white (AA) */ + --color-text-muted: rgb(107 114 128); /* gray-500 — 4.83:1 on white (AA) */ --color-text-disabled: rgb(156 163 175); /* gray-400 — 3.0:1 on white (AA large text) */ /* Semantic Tokens - Borders — visible like GitHub */ @@ -569,7 +569,7 @@ --color-text-primary: var(--dusk-100); --color-text-secondary: var(--dusk-300); --color-text-tertiary: var(--dusk-400); - --color-text-muted: var(--dusk-500); + --color-text-muted: #7c8492; /* 4.7:1 on --dusk-900 (AA) */ --color-text-disabled: var(--dusk-600); /* Borders */ @@ -605,7 +605,7 @@ --color-interactive-border-hover: rgba(59, 130, 246, 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: var(--dusk-500); + --color-text-interactive-muted: #7c8492; /* 4.7:1 on --dusk-900 (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(59, 130, 246, 0.4); @@ -656,10 +656,10 @@ --color-surface-navigation-selected-hover: rgba(59, 130, 246, 0.2); --color-text-navigation-primary: var(--dusk-300); --color-text-navigation-secondary: var(--dusk-400); - --color-text-navigation-muted: var(--dusk-500); + --color-text-navigation-muted: #7c8492; /* 4.7:1 on --dusk-900 (AA) */ --color-text-navigation-selected: var(--dusk-100); --color-text-navigation-hover: var(--dusk-100); - --color-text-navigation-section: var(--dusk-500); + --color-text-navigation-section: #7c8492; /* 4.7:1 on --dusk-900 (AA) */ --color-text-navigation-brand: var(--dusk-400); --color-border-navigation: rgba(255, 255, 255, 0.06); --color-divider-navigation: rgba(255, 255, 255, 0.05); @@ -703,7 +703,7 @@ --color-text-primary: var(--dusk-100); --color-text-secondary: var(--dusk-300); --color-text-tertiary: var(--dusk-400); - --color-text-muted: var(--dusk-500); + --color-text-muted: #7c8492; /* 5.6:1 on pure black (AA) */ --color-text-disabled: var(--dusk-600); /* Borders */ @@ -739,7 +739,7 @@ --color-interactive-border-hover: rgba(59, 130, 246, 0.75); /* Interactive text tokens */ - --color-text-interactive-muted: var(--dusk-500); + --color-text-interactive-muted: #7c8492; /* 5.6:1 on pure black (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(59, 130, 246, 0.45); @@ -790,10 +790,10 @@ --color-surface-navigation-selected-hover: rgba(59, 130, 246, 0.24); --color-text-navigation-primary: var(--dusk-300); --color-text-navigation-secondary: var(--dusk-400); - --color-text-navigation-muted: var(--dusk-500); + --color-text-navigation-muted: #7c8492; /* 5.6:1 on pure black (AA) */ --color-text-navigation-selected: var(--dusk-100); --color-text-navigation-hover: var(--dusk-100); - --color-text-navigation-section: var(--dusk-500); + --color-text-navigation-section: #7c8492; /* 5.6:1 on pure black (AA) */ --color-text-navigation-brand: var(--dusk-400); --color-border-navigation: rgba(255, 255, 255, 0.08); --color-divider-navigation: rgba(255, 255, 255, 0.06); @@ -836,7 +836,7 @@ --color-text-primary: var(--forge-gray12); --color-text-secondary: var(--forge-gray11); --color-text-tertiary: var(--forge-gray9); - --color-text-muted: var(--forge-gray7); + --color-text-muted: #93979d; /* 4.7:1 on --forge-gray2 (AA) */ --color-text-disabled: var(--forge-gray6); /* Borders — near-invisible, depth via color */ @@ -872,7 +872,7 @@ --color-interactive-border-hover: rgba(53, 116, 240, 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: var(--forge-gray7); + --color-text-interactive-muted: #93979d; /* 4.7:1 on --forge-gray2 (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(53, 116, 240, 0.4); @@ -933,10 +933,10 @@ --color-surface-navigation-selected-hover: rgba(53, 116, 240, 0.2); --color-text-navigation-primary: var(--forge-gray11); --color-text-navigation-secondary: var(--forge-gray9); - --color-text-navigation-muted: var(--forge-gray7); + --color-text-navigation-muted: #93979d; /* 4.7:1 on --forge-gray2 (AA) */ --color-text-navigation-selected: var(--forge-gray12); --color-text-navigation-hover: var(--forge-gray12); - --color-text-navigation-section: var(--forge-gray7); + --color-text-navigation-section: #93979d; /* 4.7:1 on --forge-gray2 (AA) */ --color-text-navigation-brand: var(--forge-gray9); --color-border-navigation: var(--forge-gray3); --color-divider-navigation: var(--forge-gray3); @@ -1266,7 +1266,7 @@ --color-text-primary: var(--owl-100); --color-text-secondary: var(--owl-200); --color-text-tertiary: var(--owl-300); - --color-text-muted: var(--owl-400); + --color-text-muted: #9f7275; /* 4.7:1 on --owl-900 (AA) */ --color-text-disabled: var(--owl-500); /* Borders */ @@ -1302,7 +1302,7 @@ --color-interactive-border-hover: rgba(196, 92, 92, 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: var(--owl-400); + --color-text-interactive-muted: #9f7275; /* 4.7:1 on --owl-900 (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(196, 92, 92, 0.4); @@ -1363,10 +1363,10 @@ --color-surface-navigation-selected-hover: rgba(196, 92, 92, 0.2); --color-text-navigation-primary: var(--owl-200); --color-text-navigation-secondary: var(--owl-300); - --color-text-navigation-muted: var(--owl-400); + --color-text-navigation-muted: #9f7275; /* 4.7:1 on --owl-900 (AA) */ --color-text-navigation-selected: var(--owl-100); --color-text-navigation-hover: var(--owl-100); - --color-text-navigation-section: var(--owl-400); + --color-text-navigation-section: #9f7275; /* 4.7:1 on --owl-900 (AA) */ --color-text-navigation-brand: var(--owl-300); --color-border-navigation: var(--owl-700); --color-divider-navigation: var(--owl-800); @@ -1424,7 +1424,7 @@ --color-text-primary: var(--owl-100); --color-text-secondary: var(--owl-200); --color-text-tertiary: var(--owl-300); - --color-text-muted: var(--owl-400); + --color-text-muted: #9f7275; /* 5.1:1 on pure black (AA) */ --color-text-disabled: var(--owl-500); /* Borders — slightly brighter on pure black */ @@ -1460,7 +1460,7 @@ --color-interactive-border-hover: rgba(196, 92, 92, 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: var(--owl-400); + --color-text-interactive-muted: #9f7275; /* 5.1:1 on pure black (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(196, 92, 92, 0.4); @@ -1521,10 +1521,10 @@ --color-surface-navigation-selected-hover: rgba(196, 92, 92, 0.2); --color-text-navigation-primary: var(--owl-200); --color-text-navigation-secondary: var(--owl-300); - --color-text-navigation-muted: var(--owl-400); + --color-text-navigation-muted: #9f7275; /* 5.1:1 on pure black (AA) */ --color-text-navigation-selected: var(--owl-100); --color-text-navigation-hover: var(--owl-100); - --color-text-navigation-section: var(--owl-400); + --color-text-navigation-section: #9f7275; /* 5.1:1 on pure black (AA) */ --color-text-navigation-brand: var(--owl-300); --color-border-navigation: rgba(196, 92, 92, 0.10); --color-divider-navigation: rgba(196, 92, 92, 0.06); @@ -1582,7 +1582,7 @@ --color-text-primary: var(--terra-100); --color-text-secondary: var(--terra-200); --color-text-tertiary: var(--terra-300); - --color-text-muted: var(--terra-400); + --color-text-muted: #9c7762; /* 4.7:1 on --terra-900 (AA) */ --color-text-disabled: var(--terra-500); /* Borders */ @@ -1618,7 +1618,7 @@ --color-interactive-border-hover: rgba(192, 112, 80, 0.7); /* Interactive text tokens */ - --color-text-interactive-muted: var(--terra-400); + --color-text-interactive-muted: #9c7762; /* 4.7:1 on --terra-900 (AA) */ /* Focus ring */ --color-focus-ring-subtle: rgba(192, 112, 80, 0.4); @@ -1679,10 +1679,10 @@ --color-surface-navigation-selected-hover: rgba(192, 112, 80, 0.2); --color-text-navigation-primary: var(--terra-200); --color-text-navigation-secondary: var(--terra-300); - --color-text-navigation-muted: var(--terra-400); + --color-text-navigation-muted: #9c7762; /* 4.7:1 on --terra-900 (AA) */ --color-text-navigation-selected: var(--terra-100); --color-text-navigation-hover: var(--terra-100); - --color-text-navigation-section: var(--terra-400); + --color-text-navigation-section: #9c7762; /* 4.7:1 on --terra-900 (AA) */ --color-text-navigation-brand: var(--terra-300); --color-border-navigation: var(--terra-700); --color-divider-navigation: var(--terra-800); @@ -1730,3 +1730,101 @@ --color-surface-navigation-hover: var(--gh-light-100); --color-surface-navigation-active: rgb(220 230 236); } + +/* ========================================================================== + High Contrast — opt-in AAA overrides (Settings → Appearance → High contrast) + Stamped as an additive `high-contrast` class on / alongside the + theme classes. Each block raises the muted text family to ~7:1 against that + theme's --color-bg-primary while preserving the theme's hue. Ordering mirrors + the theme blocks above so OLED/derived variants still win over their parent. + --color-text-disabled is deliberately NOT raised: WCAG 1.4.3 exempts disabled + controls and lifting it would destroy the disabled affordance. + ========================================================================== */ + +/* Light + Light Rounded (both carry the `light` class) */ +:root.high-contrast.light { + --color-text-muted: #545964; /* 7.0:1 on white */ + --color-text-interactive-muted: #545964; /* 7.0:1 on white */ + --color-text-navigation-muted: #545964; /* 7.0:1 on white */ + --color-text-navigation-section: #525a62; /* 7.0:1 on white */ +} + +/* Dark (base) */ +:root.high-contrast.dark { + --color-text-muted: #9198a1; /* 7.1:1 on --gh-black */ + --color-text-interactive-muted: #9198a1; + --color-text-navigation-muted: #9198a1; + --color-text-navigation-section: #9198a1; +} + +/* OLED */ +:root.high-contrast.oled { + --color-text-muted: #8f969f; /* 7.0:1 on pure black */ + --color-text-interactive-muted: #8f969f; + --color-text-navigation-muted: #8f969f; + --color-text-navigation-section: #8f969f; +} + +/* Dusk */ +:root.high-contrast.dusk { + --color-text-muted: #9ea3ae; /* 7.0:1 on --dusk-900 */ + --color-text-interactive-muted: #9ea3ae; + --color-text-navigation-muted: #9ea3ae; + --color-text-navigation-section: #9ea3ae; +} + +/* Dusk OLED */ +:root.high-contrast.dusk-oled { + --color-text-muted: #9096a2; /* 7.1:1 on pure black */ + --color-text-interactive-muted: #9096a2; + --color-text-navigation-muted: #9096a2; + --color-text-navigation-section: #9096a2; +} + +/* Forge */ +:root.high-contrast.forge { + --color-text-muted: #b6b9bd; /* 7.0:1 on --forge-gray2 */ + --color-text-interactive-muted: #b6b9bd; + --color-text-navigation-muted: #b6b9bd; + --color-text-navigation-section: #b6b9bd; +} + +/* Ember */ +:root.high-contrast.ember { + --color-text-muted: #b194d3; /* 7.1:1 on ember bg */ + --color-text-interactive-muted: #b194d3; + --color-text-navigation-muted: #b194d3; + --color-text-navigation-section: #b194d3; +} + +/* Aurora */ +:root.high-contrast.aurora { + --color-text-muted: #afacc1; /* 7.1:1 on aurora bg */ + --color-text-interactive-muted: #afacc1; + --color-text-navigation-muted: #afacc1; + --color-text-navigation-section: #afacc1; +} + +/* Night Owl */ +:root.high-contrast.night-owl { + --color-text-muted: #b69496; /* 7.1:1 on --owl-900 */ + --color-text-interactive-muted: #b69496; + --color-text-navigation-muted: #b69496; + --color-text-navigation-section: #b69496; +} + +/* Night Owl OLED */ +:root.high-contrast.night-owl-oled { + --color-text-muted: #b18c8e; /* 7.0:1 on pure black */ + --color-text-interactive-muted: #b18c8e; + --color-text-navigation-muted: #b18c8e; + --color-text-navigation-section: #b18c8e; +} + +/* Terracotta */ +:root.high-contrast.terracotta { + --color-text-muted: #b59888; /* 7.0:1 on --terra-900 */ + --color-text-interactive-muted: #b59888; + --color-text-navigation-muted: #b59888; + --color-text-navigation-section: #b59888; +} diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index e423dbbb..77a90727 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -82,6 +82,9 @@ export interface AppConfig { stravuServerUrl?: string; // Theme preference theme?: 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta'; + // Opt-in high contrast mode: raises muted chrome text to AAA and the terminal's + // minimumContrastRatio so dim CLI output stays legible + highContrast?: boolean; // UI scale factor (0.75 to 1.5, default 1.0) uiScale?: number; // Notification settings @@ -167,6 +170,7 @@ export interface UpdateConfigRequest { stravuApiKey?: string; stravuServerUrl?: string; theme?: AppConfig['theme']; + highContrast?: boolean; uiScale?: number; notifications?: AppConfig['notifications']; devMode?: boolean; diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index f60840a3..a6e62086 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -19,6 +19,7 @@ export type SettingsSettingId = | 'start-on-login' | 'keep-awake' | 'theme' + | 'high-contrast' | 'ui-scale' | 'sidebar-pane-rows' | 'terminal-font-family' diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 73b8e8fc..f163df84 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -48,6 +48,7 @@ export class ConfigManager extends EventEmitter { systemPromptAppend: undefined, runScript: undefined, theme: 'light-rounded', + highContrast: false, terminalFontFamily: 'Geist Mono', terminalFontSize: 14, terminalPowerMode: 'performance', diff --git a/main/src/types/config.ts b/main/src/types/config.ts index 2a4cac72..2b93aa00 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -82,6 +82,9 @@ export interface AppConfig { stravuServerUrl?: string; // Theme preference theme?: 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta'; + // Opt-in high contrast mode: raises muted chrome text to AAA and the terminal's + // minimumContrastRatio so dim CLI output stays legible + highContrast?: boolean; // UI scale factor (0.75 to 1.5, default 1.0) uiScale?: number; // Notification settings @@ -163,6 +166,7 @@ export interface UpdateConfigRequest { stravuApiKey?: string; stravuServerUrl?: string; theme?: 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta'; + highContrast?: boolean; uiScale?: number; notifications?: { playSound: boolean;