diff --git a/packages/ui/components/Settings.compactDisplay.test.tsx b/packages/ui/components/Settings.compactDisplay.test.tsx index 612b30047..db5fcc58b 100644 --- a/packages/ui/components/Settings.compactDisplay.test.tsx +++ b/packages/ui/components/Settings.compactDisplay.test.tsx @@ -14,7 +14,7 @@ const hasDom = typeof document !== 'undefined'; let host: HTMLDivElement | null = null; let root: Root | null = null; -async function openDisplayTab(isCompactTouchLayout: boolean): Promise { +async function openAppearanceTab(isCompactTouchLayout: boolean): Promise { host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host); @@ -30,10 +30,15 @@ async function openDisplayTab(isCompactTouchLayout: boolean): Promise { ); }); - const displayTab = Array.from(document.querySelectorAll('button')) + const appearanceTab = Array.from(document.querySelectorAll('button')) + .find((button) => button.textContent?.trim() === 'Appearance'); + if (!appearanceTab) throw new Error('review appearance tab did not render'); + await act(async () => appearanceTab.click()); + + const editorDisplay = Array.from(document.querySelectorAll('button')) .find((button) => button.textContent?.trim() === 'Editor'); - if (!displayTab) throw new Error('review display tab did not render'); - await act(async () => displayTab.click()); + if (!editorDisplay) throw new Error('editor display subsection did not render'); + await act(async () => editorDisplay.click()); } function styleControlButtons(): HTMLButtonElement[] { @@ -49,9 +54,9 @@ afterEach(async () => { if (hasDom) document.body.replaceChildren(); }); -describe.if(hasDom)('review Display tab diff style', () => { +describe.if(hasDom)('review Appearance tab diff style', () => { test('desktop keeps the Split/Unified control', async () => { - await openDisplayTab(false); + await openAppearanceTab(false); const options = styleControlButtons(); expect(options.map((button) => button.textContent?.trim()).sort()).toEqual(['Split', 'Unified']); @@ -59,7 +64,7 @@ describe.if(hasDom)('review Display tab diff style', () => { }); test('compact hides the control and explains the session behavior', async () => { - await openDisplayTab(true); + await openAppearanceTab(true); expect(styleControlButtons()).toHaveLength(0); expect(document.body.textContent).toContain('unified diffs for the session'); diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 27cab8fbf..4261b1a7a 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -76,8 +76,10 @@ import { } from '../utils/fileBrowser'; import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus'; import { AnalysisLayerToggle } from './AnalysisLayerToggle'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks'; +type AppearanceSection = 'theme' | 'editor'; interface SettingsProps { taterMode: boolean; @@ -457,6 +459,24 @@ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavail ); }; +const EditSuggestionsSetting: React.FC = () => { + const editSuggestions = useConfigValue('editSuggestions'); + + return ( +
+
+ Experimental +
+ configStore.set('editSuggestions', v)} + label="Edit Code to Suggest" + description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor." + /> +
+ ); +}; + const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => { const diffStyle = useConfigValue('diffStyle'); const diffOverflow = useConfigValue('diffOverflow'); @@ -466,7 +486,6 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp const diffShowBackground = useConfigValue('diffShowBackground'); const diffLineBgIntensity = useConfigValue('diffLineBgIntensity'); const diffHideWhitespace = useConfigValue('diffHideWhitespace'); - const editSuggestions = useConfigValue('editSuggestions'); const diffExpandUnchanged = useConfigValue('diffExpandUnchanged'); const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); @@ -478,21 +497,6 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp return ( <> - {/* Experimental: edit code to author suggestions */} -
-
- Experimental -
- configStore.set('editSuggestions', v)} - label="Edit Code to Suggest" - description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor." - /> -
- -
- {/* Font Family */}
@@ -872,7 +876,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const webmcpTools = useWebMcpToolsEnabled(); const [showDialog, setShowDialog] = useState(false); const settingsWasOpenRef = useRef(false); - const [themePreview, setThemePreview] = useState(false); + const [livePreview, setLivePreview] = useState(false); useEffect(() => { const wasOpen = settingsWasOpenRef.current; @@ -880,23 +884,30 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange if ( wasOpen && !showDialog - && !themePreview + && !livePreview && mode !== 'review' && configStore.get('vimModeEnabled') ) { requestVimDocumentFocus(); } - }, [mode, showDialog, themePreview]); + }, [mode, showDialog, livePreview]); useEffect(() => { - if (!themePreview) return; + if (!livePreview) return; const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setThemePreview(false); setShowDialog(true); } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setLivePreview(false); + setActiveTab('general'); + setShowDialog(false); + } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, [themePreview]); + }, [livePreview]); const [activeTab, setActiveTab] = useState('general'); + const [appearanceSection, setAppearanceSection] = useState('theme'); const gridEnabled = useConfigValue('gridEnabled'); const vimModeEnabled = useConfigValue('vimModeEnabled'); const vimHudEnabled = useConfigValue('vimHudEnabled'); @@ -937,7 +948,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const mainTabs = useMemo(() => { const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }]; - t.push({ id: 'theme', label: 'Theme' }); + t.push({ id: 'theme', label: mode === 'review' ? 'Appearance' : 'Theme' }); if (mode === 'plan') { t.push({ id: 'display', label: 'Display' }); t.push({ id: 'saving', label: 'Saving' }); @@ -945,7 +956,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange } if (mode === 'review') { t.push({ id: 'git', label: 'Git' }); - t.push({ id: 'display', label: 'Editor' }); t.push({ id: 'analysis', label: 'Analysis' }); t.push({ id: 'comments', label: 'Comments' }); if (aiProviders.length > 0) { @@ -1138,9 +1148,31 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange // controls render exactly as before. const identityEditable = isIdentityEditable(); + const isLivePreviewTab = (tab: SettingsTab) => + tab === 'theme' || (tab === 'display' && mode === 'plan'); + + const selectSettingsTab = (tab: SettingsTab) => { + const nextLivePreview = isLivePreviewTab(tab); + if (nextLivePreview === livePreview) { + setActiveTab(tab); + return; + } + + setActiveTab(tab); + setLivePreview(nextLivePreview); + }; + + const closeSettings = () => { + setLivePreview(false); + setActiveTab('general'); + setAppearanceSection('theme'); + setShowDialog(false); + }; + return ( <> - {showDialog && !themePreview && createPortal( + {showDialog && createPortal(
setShowDialog(false)} + className={livePreview + ? 'pointer-events-none fixed inset-0 z-100 flex items-center justify-center p-4' + : 'fixed inset-0 z-100 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm'} + onClick={livePreview ? undefined : closeSettings} >
e.stopPropagation()} onKeyDown={(event) => { if (event.key !== 'Escape' || event.defaultPrevented) return; event.preventDefault(); event.stopPropagation(); - setShowDialog(false); + closeSettings(); }} > {taterMode && } -
+

Settings

-
-
- -
-
-
, - document.body - )} ); }; diff --git a/packages/ui/components/ThemeTab.tsx b/packages/ui/components/ThemeTab.tsx index 67c4764ac..73b158f40 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -6,11 +6,6 @@ import { configStore } from '../config/configStore'; import { useConfigValue } from '../config/useConfig'; import { faviconDataUrl, type FaviconStyle } from '@plannotator/core/favicon'; -interface ThemeTabProps { - onPreview?: () => void; - compact?: boolean; -} - const HALVES: { id: ThemeHalf; label: string }[] = [ { id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }, @@ -27,12 +22,6 @@ const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -/** - * The favicon style choices. Rendered in both layouts, so it lives in one place. - * The compact layout drops the visible "Favicon" heading the full layout has, so - * the group carries its own accessible name and two unlabelled image buttons are - * never all a screen reader gets. - */ const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected }) => (
{FAVICON_STYLES.map(({ id, label }) => ( @@ -56,7 +45,7 @@ const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected })
); -export const ThemeTab: React.FC = ({ onPreview, compact }) => { +export const ThemeTab: React.FC = () => { const { mode, setMode, @@ -79,7 +68,7 @@ export const ThemeTab: React.FC = ({ onPreview, compact }) => { const nameOf = (id: string) => availableThemes.find(theme => theme.id === id)?.name ?? id; const summary = ( -
+
{HALVES.map(({ id, label }, index) => ( {index > 0 && ·} @@ -99,10 +88,10 @@ export const ThemeTab: React.FC = ({ onPreview, compact }) => { ); return ( -
+
{/* Mode */} -
- {!compact && } +
+
{THEME_MODES.map(({ id, label, Icon }) => ( ))}
- {!compact && manageFavicon && ( + {manageFavicon && (
)} - {!compact && ( -

- System follows your OS and switches between the two themes below. -

- )} - {compact && ( - <> - {manageFavicon && } -
{summary}
- - )} +

+ System follows your OS and switches between the two themes below. +

{/* Theme pair */} -
- {!compact && ( - <> -
- -
- - - = matched syntax colors - - {onPreview && ( - - )} -
-
- {summary} - - )} +
+
+ +
+ + + = matched syntax colors + +
+
+ {summary} {/* Which half the grid assigns to */}
@@ -187,7 +156,7 @@ export const ThemeTab: React.FC = ({ onPreview, compact }) => {
-
+
{themes.map(theme => { const isSelected = pair[half] === theme.id; const colors = theme.colors[half];