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
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 theme:contrast` gates the 15 batch themes' token pairs in `frontend/src/styles/tokens/colors.css` (text/UI/terminal contrast, high-contrast overlay, CVD separation; `--all` reports the original twelve, `--themes a,b` picks themes, `--markdown --cvd` prints PR tables — see `scripts/README.md`); `pnpm theme:screenshots` regenerates `screenshots/themes/batch/`.
- 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
34 changes: 32 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', 'synthwave', 'acid', 'tokyo-rain', 'folio', 'newsprint', 'walnut', 'amber-crt', 'teletype', 'dot-matrix', 'haar', 'abyss', 'understory', 'colorblind-safe', 'low-fatigue', 'high-legibility'];
const themeClasses = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -25,6 +25,21 @@
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
'synthwave': ['dark', 'synthwave'],
'acid': ['dark', 'acid'],
'tokyo-rain': ['dark', 'tokyo-rain'],
'folio': ['light', 'folio'],
'newsprint': ['light', 'newsprint'],
'walnut': ['dark', 'walnut'],
'amber-crt': ['dark', 'amber-crt'],
'teletype': ['light', 'teletype'],
'dot-matrix': ['dark', 'dot-matrix'],
'haar': ['light', 'haar'],
'abyss': ['dark', 'abyss'],
'understory': ['dark', 'understory'],
'colorblind-safe': ['dark', 'colorblind-safe'],
'low-fatigue': ['dark', 'low-fatigue'],
'high-legibility': ['light', 'high-legibility'],
};
const theme = validThemes.includes(savedTheme) ? savedTheme : 'light-rounded';
document.documentElement.classList.add(...themeClasses[theme]);
Expand All @@ -37,7 +52,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', 'synthwave', 'acid', 'tokyo-rain', 'folio', 'newsprint', 'walnut', 'amber-crt', 'teletype', 'dot-matrix', 'haar', 'abyss', 'understory', 'colorblind-safe', 'low-fatigue', 'high-legibility'];
const tc = {
'light': ['light'],
'light-rounded': ['light', 'light-rounded'],
Expand All @@ -51,6 +66,21 @@
'night-owl': ['dark', 'night-owl'],
'night-owl-oled': ['dark', 'night-owl', 'night-owl-oled'],
'terracotta': ['dark', 'terracotta'],
'synthwave': ['dark', 'synthwave'],
'acid': ['dark', 'acid'],
'tokyo-rain': ['dark', 'tokyo-rain'],
'folio': ['light', 'folio'],
'newsprint': ['light', 'newsprint'],
'walnut': ['dark', 'walnut'],
'amber-crt': ['dark', 'amber-crt'],
'teletype': ['light', 'teletype'],
'dot-matrix': ['dark', 'dot-matrix'],
'haar': ['light', 'haar'],
'abyss': ['dark', 'abyss'],
'understory': ['dark', 'understory'],
'colorblind-safe': ['dark', 'colorblind-safe'],
'low-fatigue': ['dark', 'low-fatigue'],
'high-legibility': ['light', 'high-legibility'],
};
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 @@ -1232,7 +1232,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
31 changes: 14 additions & 17 deletions frontend/src/components/settings/categories/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { IconButton } from '../../ui/Button';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '../../ui/Select';
Expand All @@ -12,29 +14,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_OPTION_GROUPS } 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_OPTION_GROUPS, whose ids match AppConfig.theme.
return persistence.saveConfig('theme', { theme: value as AppConfig['theme'] });
};

Expand All @@ -54,7 +42,16 @@ 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_OPTION_GROUPS.map((group) => (
<SelectGroup key={group.family}>
<SelectLabel>{group.family}</SelectLabel>
{group.options.map((theme) => (
<SelectItem key={theme.id} value={theme.id} description={theme.description}>
{theme.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
Expand Down
68 changes: 44 additions & 24 deletions frontend/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useId } from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../utils/cn';
Expand Down Expand Up @@ -116,6 +116,8 @@ const SelectContent = forwardRef<
});
SelectContent.displayName = SelectPrimitive.Content.displayName;

const SelectGroup = SelectPrimitive.Group;

const SelectLabel = forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
Expand All @@ -131,31 +133,47 @@ 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) => (
<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',
'text-text-primary',
'hover:bg-surface-secondary hover:text-text-primary',
'focus:bg-surface-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'transition-colors duration-150',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-interactive" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItemProps
>(({ className, children, description, ...props }, ref) => {
const descriptionId = useId();
return (
<SelectPrimitive.Item
ref={ref}
aria-describedby={description ? descriptionId : undefined}
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',
'text-text-primary',
'hover:bg-surface-secondary hover:text-text-primary',
'focus:bg-surface-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'transition-colors duration-150',
className
)}
{...props}
>
<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>
{description ? (
<span className="flex min-w-0 flex-col items-start">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span id={descriptionId} className="mt-0.5 block max-w-[16rem] whitespace-normal text-xs leading-tight text-text-tertiary">{description}</span>
</span>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;

const SelectSeparator = forwardRef<
Expand All @@ -175,5 +193,7 @@ export {
SelectValue,
SelectTrigger,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
};
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';
Loading
Loading