Skip to content
Closed
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
10 changes: 8 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', 'night-owl', 'night-owl-oled', 'terracotta'];
const validThemes = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta', 'folio', 'newsprint', 'walnut'];
const themeClasses = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -25,6 +25,9 @@
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
'folio': ['light', 'folio'],
'newsprint': ['light', 'newsprint'],
'walnut': ['dark', 'walnut'],
};
const theme = validThemes.includes(savedTheme) ? savedTheme : 'light-rounded';
document.documentElement.classList.add(...themeClasses[theme]);
Expand All @@ -37,7 +40,7 @@
<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', 'night-owl', 'night-owl-oled', 'terracotta'];
const vt = ['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta', 'folio', 'newsprint', 'walnut'];
const tc = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -51,6 +54,9 @@
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
'folio': ['light', 'folio'],
'newsprint': ['light', 'newsprint'],
'walnut': ['dark', 'walnut'],
};
const bt = vt.includes(t) ? t : 'light-rounded';
document.body.classList.add(...tc[bt]);
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export function HomePage() {
type="button"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border-secondary bg-surface-tertiary px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-interactive"
>
<span>{{ light: 'Light (sharp)', 'light-rounded': 'Light (rounded)', dark: 'Dark (sharp)', oled: 'OLED Black (sharp)', dusk: 'Dusk', 'dusk-oled': 'Dusk (OLED)', forge: 'Forge', ember: 'Ember', aurora: 'Aurora', 'night-owl': 'Night Owl', 'night-owl-oled': 'Night Owl (OLED)', terracotta: 'Terracotta' }[theme]}</span>
<span>{{ light: 'Light (sharp)', 'light-rounded': 'Light (rounded)', dark: 'Dark (sharp)', oled: 'OLED Black (sharp)', dusk: 'Dusk', 'dusk-oled': 'Dusk (OLED)', forge: 'Forge', ember: 'Ember', aurora: 'Aurora', 'night-owl': 'Night Owl', 'night-owl-oled': 'Night Owl (OLED)', terracotta: 'Terracotta', folio: 'Folio', newsprint: 'Newsprint', walnut: 'Walnut' }[theme]}</span>
<ChevronDown className="w-3 h-3 text-text-tertiary" />
</button>
}
Expand All @@ -328,6 +328,9 @@ export function HomePage() {
{ id: 'ember', label: 'Ember', onClick: () => setTheme('ember') },
{ id: 'aurora', label: 'Aurora', onClick: () => setTheme('aurora') },
{ id: 'terracotta', label: 'Terracotta', onClick: () => setTheme('terracotta') },
{ id: 'folio', label: 'Folio', description: 'Paper & ink — warm cream, near-black type, one vermilion accent.', onClick: () => setTheme('folio') },
{ id: 'newsprint', label: 'Newsprint', description: 'Cool grey stock, graphite type, halftone surfaces, ink-blue accent.', onClick: () => setTheme('newsprint') },
{ id: 'walnut', label: 'Walnut', description: 'Vellum & walnut — warm brown-black, parchment type, brass accent.', onClick: () => setTheme('walnut') },
{ id: 'light', label: 'Light (sharp)', onClick: () => setTheme('light') },
{ id: 'dark', label: 'Dark (sharp)', onClick: () => setTheme('dark') },
{ id: 'oled', label: 'OLED Black (sharp)', onClick: () => setTheme('oled') },
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/panels/diff/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DiffHighlighter } from '@git-diff-view/shiki';
import { getDiffViewHighlighter } from '@git-diff-view/shiki';
import { FileText, ChevronRight, ChevronDown, ExternalLink, ChevronsUpDown, ChevronsDownUp } from 'lucide-react';
import type { DiffViewerProps, FileDiff } from '../../../types/diff';
import { useTheme } from '../../../contexts/ThemeContext';
import { isLightTheme, useTheme } from '../../../contexts/ThemeContext';
import { useScrollSurface } from '../../../hooks/useScrollSurface';
import "@git-diff-view/react/styles/diff-view.css";

Expand Down Expand Up @@ -141,7 +141,7 @@ export interface DiffViewerHandle {

const DiffViewer = memo(forwardRef<DiffViewerHandle, DiffViewerProps>(({ files, className = '', sessionId, onOpenInEditor }, ref) => {
const { theme } = useTheme();
const isDarkMode = theme !== 'light' && theme !== 'light-rounded';
const isDarkMode = !isLightTheme(theme);
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(new Set());
const [highlighter, setHighlighter] = useState<DiffHighlighter | null>(null);
const viewerRef = useRef<HTMLDivElement>(null);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/panels/editor/FileEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useTree } from '@headless-tree/react';
import { asyncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, expandAllFeature } from '@headless-tree/core';
import type { ItemInstance } from '@headless-tree/core';
import { MonacoErrorBoundary } from '../../MonacoErrorBoundary';
import { useTheme } from '../../../contexts/ThemeContext';
import { isLightTheme, useTheme } from '../../../contexts/ThemeContext';
import { debounce } from '../../../utils/debounce';
import { devLog } from '../../../utils/console';
import { MarkdownPreview } from '../../MarkdownPreview';
Expand Down Expand Up @@ -1233,7 +1233,7 @@ export function FileEditor({
}, [binaryBlobUrl]);

const { theme } = useTheme();
const isDarkMode = theme !== 'light' && theme !== 'light-rounded';
const isDarkMode = !isLightTheme(theme);
const hasUnsavedChanges = fileContent !== originalContent;

// Wrap onResize callback to avoid recreating
Expand Down
41 changes: 18 additions & 23 deletions frontend/src/components/panels/logPanel/LogsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Search, X, Download, Trash2, ChevronUp, ChevronDown, Filter, Copy, Chec
import { cn } from '../../../utils/cn';
import AnsiToHtml from 'ansi-to-html';
import { useTheme } from '../../../contexts/ThemeContext';
import { getTerminalTheme } from '../../../utils/terminalTheme';
import { LiveRegion } from '../../ui/LiveRegion';
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../../stores/configStore';
import { useScrollSurface } from '../../../hooks/useScrollSurface';
Expand Down Expand Up @@ -46,34 +47,28 @@ export const LogsView: React.FC<LogsViewProps> = ({ sessionId, isVisible }) => {
const { theme } = useTheme();
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));

// Create ANSI to HTML converter with theme-aware colors
// ANSI → HTML converter using the active theme's terminal palette
// (--color-terminal-* tokens), so log colours match the terminal panel.
// Read in an effect: the theme classes are stamped on <html> by
// ThemeProvider's layout effect, which has run by the time this fires.
const [terminalPalette, setTerminalPalette] = useState(getTerminalTheme);
useEffect(() => {
setTerminalPalette(getTerminalTheme());
}, [theme]);
const ansiConverter = useMemo(() => {
const isLight = theme === 'light' || theme === 'light-rounded';
const p = terminalPalette;
return new AnsiToHtml({
fg: isLight ? '#1f2328' : '#e5e7eb',
bg: isLight ? '#ffffff' : '#0a0a0a',
fg: p.foreground,
bg: p.background,
newline: true,
escapeXML: true,
colors: {
0: isLight ? '#1f2328' : '#000000', // Black
1: isLight ? '#cf222e' : '#ef4444', // Red
2: isLight ? '#1a7f37' : '#10b981', // Green
3: isLight ? '#9a6700' : '#f59e0b', // Yellow
4: isLight ? '#2563eb' : '#3b82f6', // Blue
5: isLight ? '#8250df' : '#a855f7', // Magenta
6: isLight ? '#0891b2' : '#06b6d4', // Cyan
7: isLight ? '#6b7280' : '#e5e7eb', // White
8: isLight ? '#6b7280' : '#6b7280', // Bright Black (Gray)
9: isLight ? '#ef4444' : '#f87171', // Bright Red
10: isLight ? '#22c55e' : '#34d399', // Bright Green
11: isLight ? '#eab308' : '#fbbf24', // Bright Yellow
12: isLight ? '#3b82f6' : '#60a5fa', // Bright Blue
13: isLight ? '#a855f7' : '#c084fc', // Bright Magenta
14: isLight ? '#06b6d4' : '#22d3ee', // Bright Cyan
15: isLight ? '#1f2328' : '#ffffff', // Bright White
}
// ANSI slots 0–15; getTerminalTheme always fills these, the fallback only satisfies ITheme's optional typing.
colors: [
p.black, p.red, p.green, p.yellow, p.blue, p.magenta, p.cyan, p.white,
p.brightBlack, p.brightRed, p.brightGreen, p.brightYellow, p.brightBlue, p.brightMagenta, p.brightCyan, p.brightWhite,
].map((color) => color ?? p.foreground ?? '#808080'),
});
}, [theme]);
}, [terminalPalette]);

// Load existing logs when component mounts or session changes
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import { ImmediateToggle, SegmentedControl } from '../SettingsControls';
import type { SettingsPersistence } from '../useSettingsPersistence';
import type { AppConfig } from '../../../types/config';

const THEMES: Array<{ id: NonNullable<AppConfig['theme']>; label: string }> = [
const THEMES: Array<{ id: NonNullable<AppConfig['theme']>; label: string; description?: string }> = [
{ id: 'light-rounded', label: 'Light (rounded)' },
{ id: 'light', label: 'Light (sharp)' },
{ id: 'folio', label: 'Folio', description: 'Paper & ink — warm cream, near-black type, one vermilion accent.' },
{ id: 'newsprint', label: 'Newsprint', description: 'Cool grey stock, graphite type, halftone surfaces, ink-blue accent.' },
{ id: 'walnut', label: 'Walnut', description: 'Vellum & walnut — warm brown-black, parchment type, brass accent.' },
{ id: 'forge', label: 'Forge' },
{ id: 'night-owl', label: 'Night Owl' },
{ id: 'night-owl-oled', label: 'Night Owl (OLED)' },
Expand Down Expand Up @@ -54,7 +57,11 @@ export function AppearanceSettings({ persistence }: { persistence: SettingsPersi
>
<SelectTrigger aria-label="Theme"><SelectValue /></SelectTrigger>
<SelectContent>
{THEMES.map((theme) => <SelectItem key={theme.id} value={theme.id}>{theme.label}</SelectItem>)}
{THEMES.map((theme) => (
<SelectItem key={theme.id} value={theme.id} description={theme.description}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,15 @@ const SelectLabel = forwardRef<
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;

interface SelectItemProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
/** Optional one-line hint rendered under the label inside the list (never in the trigger). */
description?: React.ReactNode;
}

const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
SelectItemProps
>(({ className, children, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
Expand All @@ -148,12 +153,19 @@ const SelectItem = forwardRef<
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className={cn('absolute right-2 flex h-3.5 w-3.5 items-center justify-center', description && 'top-2')}>
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-interactive" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description ? (
<span className="flex min-w-0 flex-col items-start">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span className="mt-0.5 text-xs leading-tight text-text-tertiary">{description}</span>
</span>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/contexts/ThemeContext.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { ThemeProvider } from './ThemeProvider';
export { useTheme } from './useTheme';
export { isLightTheme } from './themeContextValue';
32 changes: 12 additions & 20 deletions frontend/src/contexts/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,10 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useLayoutEffect, useState } from 'react';
import { useConfigStore } from '../stores/configStore';
import { ThemeContext, type Theme } from './themeContextValue';
import { THEME_CLASSES, ThemeContext, type Theme } from './themeContextValue';

const VALID_THEMES = new Set<string>(['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta']);
const THEME_CLASSES = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
'dark': ['dark'],
'oled': ['dark', 'oled'],
'dusk': ['dark', 'dusk'],
'dusk-oled': ['dark', 'dusk', 'dusk-oled'],
'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'],
} satisfies Record<Theme, string[]>;
const VALID_THEMES = new Set<string>(Object.keys(THEME_CLASSES));
// Every class any theme can stamp — removed wholesale before applying the next theme.
const ALL_THEME_CLASSES = [...new Set(Object.values(THEME_CLASSES).flat())];
const isValidTheme = (theme: string): theme is Theme => VALID_THEMES.has(theme);

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
Expand Down Expand Up @@ -46,13 +34,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
}, [config?.highContrast]);

useEffect(() => {
// Layout effect (not passive): descendants read the stamped classes via
// getComputedStyle in their own useEffects (terminal palette, log ANSI
// colours), and React runs child passive effects before the parent's — a
// passive effect here would leave them one theme behind.
useLayoutEffect(() => {
const root = document.documentElement;
const body = document.body;

// Remove ALL theme classes from both root and body
root.classList.remove('light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta');
body.classList.remove('light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta');
root.classList.remove(...ALL_THEME_CLASSES);
body.classList.remove(...ALL_THEME_CLASSES);

const themeClasses = THEME_CLASSES[theme];
root.classList.add(...themeClasses);
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/contexts/themeClasses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { THEME_CLASSES } from './themeContextValue';

/**
* frontend/index.html stamps the theme classes before React loads (to avoid a
* flash) and duplicates THEME_CLASSES twice — once for <html>, once for <body>.
* PR #362 found that copy had silently drifted; this keeps it honest.
*/
const INDEX_HTML = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../../index.html'),
'utf8',
);

/** Parse a `{ 'id': ['a', 'b'], ... }` object literal from the bootstrap script into id → class list entries. */
function parseClassMap(source: string) {
return Object.fromEntries(
[...source.matchAll(/'([\w-]+)':\s*\[([^\]]*)\]/g)].map((entry) => [
entry[1],
[...entry[2].matchAll(/'([\w-]+)'/g)].map((m) => m[1]),
]),
);
}

function parseIdList(source: string): string[] {
return [...source.matchAll(/'([\w-]+)'/g)].map((m) => m[1]);
}

describe('index.html theme bootstrap', () => {
const classMaps = [...INDEX_HTML.matchAll(/const (?:themeClasses|tc) = \{([\s\S]*?)\};/g)].map((m) => parseClassMap(m[1]));
const idLists = [...INDEX_HTML.matchAll(/const (?:validThemes|vt) = \[([^\]]*)\];/g)].map((m) => parseIdList(m[1]));

it('has one head and one body copy of the class map and valid-theme list', () => {
expect(classMaps).toHaveLength(2);
expect(idLists).toHaveLength(2);
});

it('keeps both copies identical to THEME_CLASSES', () => {
for (const map of classMaps) expect(map).toEqual(THEME_CLASSES);
for (const ids of idLists) expect(ids.sort()).toEqual(Object.keys(THEME_CLASSES).sort());
});

it('every theme composes on the light or dark base', () => {
for (const [theme, classes] of Object.entries(THEME_CLASSES)) {
expect(['light', 'dark'], `${theme} must start with a base class`).toContain(classes[0]);
}
});
});
28 changes: 27 additions & 1 deletion frontend/src/contexts/themeContextValue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import { createContext } from 'react';

export type Theme = 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta';
export type Theme = 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta' | 'folio' | 'newsprint' | 'walnut';

/**
* Class list stamped on <html>/<body> per theme. Every theme composes on the
* `light` or `dark` base, so index 0 is the base and decides light-vs-dark
* behaviour for code surfaces (diff view, Monaco, logs, logo).
* Keep in sync with the pre-React bootstrap in frontend/index.html.
*/
export const THEME_CLASSES = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
'dark': ['dark'],
'oled': ['dark', 'oled'],
'dusk': ['dark', 'dusk'],
'dusk-oled': ['dark', 'dusk', 'dusk-oled'],
'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'],
'folio': ['light', 'folio'],
'newsprint': ['light', 'newsprint'],
'walnut': ['dark', 'walnut'],
} satisfies Record<Theme, string[]>;

export const isLightTheme = (theme: Theme): boolean => THEME_CLASSES[theme][0] === 'light';

export interface ThemeContextType {
theme: Theme;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/usePaneLogo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import paneLogoDark from '../assets/pane-logo-dark.svg';
import paneLogoLight from '../assets/pane-logo-light.svg';
import { useTheme } from '../contexts/ThemeContext';
import { isLightTheme, useTheme } from '../contexts/ThemeContext';

export function usePaneLogo(): string {
const { theme } = useTheme();
return theme === 'light' || theme === 'light-rounded' ? paneLogoLight : paneLogoDark;
return isLightTheme(theme) ? paneLogoLight : paneLogoDark;
}
Loading
Loading