Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<script>
// Apply theme class before React loads to prevent flash
const savedTheme = localStorage.getItem('theme');
const validThemes = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora'];
const validThemes = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta'];
const themeClasses = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -22,16 +22,22 @@
'forge': ['dark', 'forge'],
'ember': ['dark', 'ember'],
'aurora': ['dark', 'aurora'],
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
};
const theme = validThemes.includes(savedTheme) ? savedTheme : 'light-rounded';
document.documentElement.classList.add(...themeClasses[theme]);
if (localStorage.getItem('high-contrast') === 'true') {
document.documentElement.classList.add('high-contrast');
}
</script>
</head>
<body>
<script>
// Also apply to body (mirrors the head script logic)
const t = localStorage.getItem('theme');
const vt = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora'];
const vt = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta'];
const tc = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -42,9 +48,15 @@
'forge': ['dark', 'forge'],
'ember': ['dark', 'ember'],
'aurora': ['dark', 'aurora'],
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
};
const bt = vt.includes(t) ? t : 'light-rounded';
document.body.classList.add(...tc[bt]);
if (localStorage.getItem('high-contrast') === 'true') {
document.body.classList.add('high-contrast');
}
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
Expand Down
25 changes: 20 additions & 5 deletions frontend/src/components/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -298,7 +314,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = 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);
Expand Down Expand Up @@ -826,7 +842,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = 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) => {
Expand Down Expand Up @@ -1804,15 +1820,14 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = 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)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/settings/catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -55,6 +55,18 @@ export function AppearanceSettings({ persistence }: { persistence: SettingsPersi
</Select>
</div>
</SettingRow>
<SettingRow
settingId="high-contrast"
label="High contrast"
description="Raise text and terminal contrast to AAA. Helps with CLI output that renders too dim."
saveState={persistence.saveStates['high-contrast']}
>
<ImmediateToggle
label="High contrast"
value={config.highContrast === true}
onSave={(value) => persistence.saveConfig('high-contrast', { highContrast: value })}
/>
</SettingRow>
<SettingRow
settingId="ui-scale"
label="UI scale"
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const isValidTheme = (t: string): t is Theme => 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<ThemeContextType | undefined>(undefined);
Expand All @@ -36,6 +39,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
return 'light-rounded';
});
const [highContrast, setHighContrast] = useState<boolean>(() => localStorage.getItem('high-contrast') === 'true');

// Sync theme from config when it loads
useEffect(() => {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -73,7 +93,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
};

return (
<ThemeContext.Provider value={{ theme, setTheme: updateTheme }}>
<ThemeContext.Provider value={{ theme, setTheme: updateTheme, highContrast }}>
{children}
</ThemeContext.Provider>
);
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/remote/hooks/useRemoteTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Raise the remote threshold for dim text

When Remote Pane renders SGR 2 (dim) output from Claude Code or Codex, xterm halves this configured contrast threshold—the same behavior documented in TerminalPanel.tsx when choosing a value of 9—so this value only enforces approximately 2.25:1 rather than the claimed AA floor. Since the remote terminal has no toggle and always uses this static value, set it to 9 as well if dim CLI output is meant to remain legible.

Useful? React with 👍 / 👎.

theme: {
background: '#010409',
foreground: '#e6edf3',
Expand Down
Loading
Loading