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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Lint: `pnpm lint`; Type-check: `pnpm typecheck` (runs per package). The root lint command is the single entry point for blocking Oxlint and Knip checks, residual ESLint, and advisory anti-slop checks.
- Detailed advisory output: `pnpm lint:ox:extra:details`; accessibility scan: `pnpm a11y:scan` (install Chromium once with `pnpm exec playwright install chromium`); opt-in render evidence: `pnpm perf:scan`.
- Tests (E2E): `pnpm test`, `pnpm test:ui`, CI configs in `playwright.ci*.config.ts`.
- Themes: `pnpm check:theme-contrast` gates every theme in `frontend/src/styles/tokens/colors.css` (AA text/UI pairs, terminal ANSI palette, high-contrast overlay); `THEME_SCREENSHOTS=1 pnpm test -- tests/theme-screenshots.spec.ts` regenerates `screenshots/themes/`.
- Main unit tests (if added): `pnpm --filter main test`, coverage: `pnpm --filter main run test:coverage`.
- Releases must follow `docs/RELEASE_INSTRUCTIONS.md` and run from a clean `main` checkout whose `HEAD` matches `origin/main`.

Expand Down
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', 'haar', 'abyss', 'understory'];
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'],
'haar': ['light', 'haar'],
'abyss': ['dark', 'abyss'],
'understory': ['dark', 'understory'],
};
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', 'haar', 'abyss', 'understory'];
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'],
'haar': ['light', 'haar'],
'abyss': ['dark', 'abyss'],
'understory': ['dark', 'understory'],
};
const bt = vt.includes(t) ? t : 'light-rounded';
document.body.classList.add(...tc[bt]);
Expand Down
25 changes: 9 additions & 16 deletions frontend/src/components/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Toggle } from './ui/Toggle';
import { AddProjectDialog } from './AddProjectDialog';
import { CloneFromGitHubDialog } from './CloneFromGitHubDialog';
import { formatDistanceToNow, isValidTimestamp } from '../utils/timestampUtils';
import { THEME_OPTIONS, getThemeLabel } from '../utils/themeOptions';
import type { Project } from '../types/project';
import type { Session } from '../types/session';

Expand Down Expand Up @@ -314,27 +315,19 @@ 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>{getThemeLabel(theme)}</span>
<ChevronDown className="w-3 h-3 text-text-tertiary" />
</button>
}
items={[
{ id: 'light-rounded', label: 'Light (rounded)', onClick: () => setTheme('light-rounded') },
{ id: 'forge', label: 'Forge', onClick: () => setTheme('forge') },
{ id: 'night-owl', label: 'Night Owl', onClick: () => setTheme('night-owl') },
{ id: 'night-owl-oled', label: 'Night Owl (OLED)', onClick: () => setTheme('night-owl-oled') },
{ id: 'dusk-oled', label: 'Dusk (OLED)', onClick: () => setTheme('dusk-oled') },
{ id: 'dusk', label: 'Dusk', onClick: () => setTheme('dusk') },
{ id: 'ember', label: 'Ember', onClick: () => setTheme('ember') },
{ id: 'aurora', label: 'Aurora', onClick: () => setTheme('aurora') },
{ id: 'terracotta', label: 'Terracotta', onClick: () => setTheme('terracotta') },
{ 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') },
]}
items={THEME_OPTIONS.map((option) => ({
id: option.id,
label: option.label,
description: option.description,
onClick: () => setTheme(option.id),
}))}
selectedId={theme}
position="bottom-right"
width="sm"
width="lg"
/>
</div>

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
4 changes: 2 additions & 2 deletions frontend/src/components/panels/logPanel/LogsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Search, X, Download, Trash2, ChevronUp, ChevronDown, Filter, Copy, Check } from 'lucide-react';
import { cn } from '../../../utils/cn';
import AnsiToHtml from 'ansi-to-html';
import { useTheme } from '../../../contexts/ThemeContext';
import { isLightTheme, useTheme } from '../../../contexts/ThemeContext';
import { LiveRegion } from '../../ui/LiveRegion';
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../../stores/configStore';
import { useScrollSurface } from '../../../hooks/useScrollSurface';
Expand Down Expand Up @@ -48,7 +48,7 @@ export const LogsView: React.FC<LogsViewProps> = ({ sessionId, isVisible }) => {

// Create ANSI to HTML converter with theme-aware colors
const ansiConverter = useMemo(() => {
const isLight = theme === 'light' || theme === 'light-rounded';
const isLight = isLightTheme(theme);
return new AnsiToHtml({
fg: isLight ? '#1f2328' : '#e5e7eb',
bg: isLight ? '#ffffff' : '#0a0a0a',
Expand Down
24 changes: 7 additions & 17 deletions frontend/src/components/settings/categories/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,15 @@ import { SettingRow, SettingsPage } from '../SettingRow';
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 }> = [
{ id: 'light-rounded', label: 'Light (rounded)' },
{ id: 'light', label: 'Light (sharp)' },
{ id: 'forge', label: 'Forge' },
{ id: 'night-owl', label: 'Night Owl' },
{ id: 'night-owl-oled', label: 'Night Owl (OLED)' },
{ id: 'dusk', label: 'Dusk' },
{ id: 'dusk-oled', label: 'Dusk (OLED)' },
{ id: 'ember', label: 'Ember' },
{ id: 'aurora', label: 'Aurora' },
{ id: 'terracotta', label: 'Terracotta' },
{ id: 'dark', label: 'Dark (sharp)' },
{ id: 'oled', label: 'OLED Black (sharp)' },
];
import { THEME_OPTIONS } from '../../../utils/themeOptions';

export function AppearanceSettings({ persistence }: { persistence: SettingsPersistence }) {
const config = persistence.config!;
const scale = config.uiScale ?? 1;

const saveScale = (value: number) => persistence.saveConfig('ui-scale', { uiScale: value });
const saveTheme = (value: string) => {
// SAFETY: Theme values come from the THEMES list, whose ids match AppConfig.theme.
// SAFETY: Theme values come from THEME_OPTIONS, whose ids match AppConfig.theme.
return persistence.saveConfig('theme', { theme: value as AppConfig['theme'] });
};

Expand All @@ -54,7 +40,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>)}
{THEME_OPTIONS.map((theme) => (
<SelectItem key={theme.id} value={theme.id} description={theme.description}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,16 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;

const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
/** Optional secondary line rendered under the label inside the list only (not in the trigger). */
description?: string;
}
>(({ className, children, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none',
'relative flex w-full cursor-default select-none rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none',
description ? 'flex-col items-start gap-0.5' : 'items-center',
'text-text-primary',
'hover:bg-surface-secondary hover:text-text-primary',
'focus:bg-surface-secondary focus:text-text-primary',
Expand All @@ -148,12 +152,13 @@ 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="text-xs leading-snug text-text-muted">{description}</span>}
</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';
9 changes: 6 additions & 3 deletions frontend/src/contexts/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useConfigStore } from '../stores/configStore';
import { 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 VALID_THEMES = new Set<string>(['light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta', 'haar', 'abyss', 'understory']);
const THEME_CLASSES = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -16,6 +16,9 @@ const THEME_CLASSES = {
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
'haar': ['light', 'haar'],
'abyss': ['dark', 'abyss'],
'understory': ['dark', 'understory'],
} satisfies Record<Theme, string[]>;
const isValidTheme = (theme: string): theme is Theme => VALID_THEMES.has(theme);

Expand Down Expand Up @@ -51,8 +54,8 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
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('light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta', 'haar', 'abyss', 'understory');
body.classList.remove('light', 'light-rounded', 'dark', 'oled', 'dusk', 'dusk-oled', 'forge', 'ember', 'aurora', 'night-owl', 'night-owl-oled', 'terracotta', 'haar', 'abyss', 'understory');

const themeClasses = THEME_CLASSES[theme];
root.classList.add(...themeClasses);
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/contexts/themeContextValue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
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' | 'haar' | 'abyss' | 'understory';

const LIGHT_THEMES: ReadonlySet<Theme> = new Set<Theme>(['light', 'light-rounded', 'haar']);

// Themes composed on the `light` base class (see THEME_CLASSES in ThemeProvider).
export const isLightTheme = (theme: Theme): boolean => LIGHT_THEMES.has(theme);

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