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;