diff --git a/desktop/src/features/onboarding/ui/ThemeStep.tsx b/desktop/src/features/onboarding/ui/ThemeStep.tsx index ffe8c47fd3..9a77dcf3f6 100644 --- a/desktop/src/features/onboarding/ui/ThemeStep.tsx +++ b/desktop/src/features/onboarding/ui/ThemeStep.tsx @@ -1,7 +1,6 @@ import { Check } from "lucide-react"; import * as React from "react"; -import { createThemeVars, hexToHsl } from "@/shared/theme/adaptive-theme"; import { ACCENT_COLORS, ACCENT_STORAGE_KEY, @@ -13,10 +12,16 @@ import { ONBOARDING_DEFAULT_THEME_NAME, SYNTAX_THEMES, type SyntaxThemeName, - extractThemeInfo, - isLightTheme, - loadThemeData, } from "@/shared/theme/theme-loader"; +import { + ThemePreviewFrame, + type ThemePreviewVars, +} from "@/shared/theme/ThemePreviewFrame"; +import { + getThemeFallbackPreviewVars, + useThemePreviewVars, + withAccentPreviewVars, +} from "@/shared/theme/useThemePreviewVars"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { StepProgress } from "@/shared/ui/step-progress"; @@ -31,30 +36,6 @@ type ThemeStepProps = { direction: OnboardingTransitionDirection; }; -type ThemePreviewVars = Record; - -const LIGHT_PREVIEW_VARS: ThemePreviewVars = { - "--background": "0 0% 100%", - "--border": "0 0% 89.8%", - "--foreground": "0 0% 9%", - "--muted": "0 0% 96.1%", - "--muted-foreground": "0 0% 45.1%", - "--primary": "0 0% 9%", - "--sidebar-background": "0 0% 98%", - "--sidebar-foreground": "0 0% 9%", -}; - -const DARK_PREVIEW_VARS: ThemePreviewVars = { - "--background": "0 0% 3.9%", - "--border": "0 0% 14.9%", - "--foreground": "0 0% 98%", - "--muted": "0 0% 14.9%", - "--muted-foreground": "0 0% 63.9%", - "--primary": "0 0% 98%", - "--sidebar-background": "0 0% 0%", - "--sidebar-foreground": "0 0% 98%", -}; - const GRADUAL_BLUR_LEVELS = [0.5, 1.25, 2.5, 4.5, 7, 10] as const; const THEME_TILE_WIDTH = 174; const THEME_TILE_HEIGHT = 160; @@ -66,21 +47,6 @@ const THEME_SCROLL_MAX_HEIGHT = THEME_TILE_GAP * THEME_VISIBLE_ROW_COUNT + THEME_ROW_PEEK_HEIGHT; -type ThemePreviewVarsByTheme = Partial< - Record ->; - -let themePreviewVarsCache: ThemePreviewVarsByTheme | null = null; -let themePreviewVarsPromise: Promise | null = null; - -function hsl(vars: ThemePreviewVars | null, key: string) { - return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]})`; -} - -function hslAlpha(vars: ThemePreviewVars | null, key: string, alpha: number) { - return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]} / ${alpha})`; -} - function contrastColorForHex(hex: string) { const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!match) { @@ -101,28 +67,6 @@ function formatThemeLabel(name: string): string { .join(" "); } -function withAccentPreviewVars( - vars: ThemePreviewVars | null, - accentColor: string, -): ThemePreviewVars | null { - if (!vars) { - return null; - } - - if (accentColor === NEUTRAL_ACCENT) { - return { - ...vars, - "--primary": vars["--foreground"], - "--primary-foreground": vars["--background"], - }; - } - - return { - ...vars, - "--primary": hexToHsl(accentColor), - }; -} - function getOrderedThemes() { return [ ONBOARDING_DEFAULT_THEME_NAME, @@ -130,234 +74,7 @@ function getOrderedThemes() { ]; } -function getThemeFallbackPreviewVars(name: SyntaxThemeName) { - return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS; -} - -async function loadThemePreviewVars(name: SyntaxThemeName) { - const themeData = await loadThemeData(name); - const info = extractThemeInfo(name, themeData); - const { vars } = createThemeVars(info.bg, info.fg, info.comment, { - added: info.added, - deleted: info.deleted, - modified: info.modified, - }); - return [name, vars] as const; -} - -export function preloadThemePreviewVars() { - if (themePreviewVarsCache) { - return Promise.resolve(themePreviewVarsCache); - } - - if (!themePreviewVarsPromise) { - themePreviewVarsPromise = Promise.all( - SYNTAX_THEMES.map((name) => loadThemePreviewVars(name)), - ) - .then((entries) => { - const previewVars = Object.fromEntries( - entries, - ) as ThemePreviewVarsByTheme; - themePreviewVarsCache = previewVars; - return previewVars; - }) - .catch((error) => { - themePreviewVarsPromise = null; - throw error; - }); - } - - return themePreviewVarsPromise; -} - -function useThemePreviewVars() { - const [previewVarsByTheme, setPreviewVarsByTheme] = - React.useState(() => themePreviewVarsCache ?? {}); - - React.useEffect(() => { - let canceled = false; - - void preloadThemePreviewVars() - .then((previewVars) => { - if (!canceled) { - setPreviewVarsByTheme(previewVars); - } - }) - .catch(() => { - if (!canceled) { - setPreviewVarsByTheme({}); - } - }); - - return () => { - canceled = true; - }; - }, []); - - return previewVarsByTheme; -} - -function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) { - const clipId = React.useId().replace(/:/g, ""); - const background = hsl(vars, "--background"); - const border = hsl(vars, "--border"); - const foreground = hsl(vars, "--foreground"); - const mutedForeground = hsl(vars, "--muted-foreground"); - const primary = hsl(vars, "--primary"); - const primarySoft = hslAlpha(vars, "--primary", 0.68); - const sidebar = hsl(vars, "--sidebar-background"); - const sidebarForeground = hslAlpha(vars, "--sidebar-foreground", 0.58); - - return ( - - ); -} +export { preloadThemePreviewVars } from "@/shared/theme/useThemePreviewVars"; function GradualBottomBlur() { return ( @@ -394,31 +111,6 @@ function GradualBottomBlur() { ); } -function ThemePreviewFrame({ - className, - vars, -}: { - className?: string; - vars: ThemePreviewVars | null; -}) { - return ( -
-
- -
-
- ); -} - function ThemeTile({ isActive, name, diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 944b322464..ee89f1795f 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from "react"; +import { useMemo } from "react"; import { BellRing, Bot, @@ -9,14 +9,10 @@ import { Keyboard, LayoutTemplate, LockKeyhole, - Monitor, MonitorCog, - Moon, - Search, Smartphone, Smile, Stethoscope, - Sun, UserRound, type LucideIcon, } from "lucide-react"; @@ -33,8 +29,21 @@ import { NEUTRAL_ACCENT, useTheme, } from "@/shared/theme/ThemeProvider"; -import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; -import { Switch } from "@/shared/ui/switch"; +import { + LIGHT_THEMES, + SYNTAX_THEMES, + type SyntaxThemeName, + getThemePair, +} from "@/shared/theme/theme-loader"; +import { + ThemePreviewFrame, + type ThemePreviewVars, +} from "@/shared/theme/ThemePreviewFrame"; +import { + getThemeFallbackPreviewVars, + useThemePreviewVars, + withAccentPreviewVars, +} from "@/shared/theme/useThemePreviewVars"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; @@ -189,37 +198,171 @@ function formatThemeLabel(name: string): string { .join(" "); } +/** + * Categorize themes into three groups: + * 1. Paired — themes with both a light and dark variant (auto-switches with system) + * 2. Light-only — light themes with no dark counterpart + * 3. Dark-only — dark themes with no light counterpart + * + * For paired themes, we deduplicate by only keeping the light member + * (the dark member is shown alongside it as a preview). + */ +function useThemeCategories() { + return useMemo(() => { + const pairedLight: SyntaxThemeName[] = []; + const lightOnly: SyntaxThemeName[] = []; + const darkOnly: SyntaxThemeName[] = []; + + // Track which themes are the "dark side" of a pair so we skip them + const darkPairMembers = new Set(); + for (const name of SYNTAX_THEMES) { + if (LIGHT_THEMES.has(name)) { + const pair = getThemePair(name); + if (pair) { + darkPairMembers.add(pair); + } + } + } + + for (const name of SYNTAX_THEMES) { + // Skip dark members of pairs — they'll be shown alongside their light counterpart + if (darkPairMembers.has(name)) continue; + + if (LIGHT_THEMES.has(name)) { + const pair = getThemePair(name); + if (pair) { + pairedLight.push(name); + } else { + lightOnly.push(name); + } + } else { + darkOnly.push(name); + } + } + + return { pairedLight, lightOnly, darkOnly }; + }, []); +} + +function PairedThemeTile({ + isActive, + lightName, + lightVars, + darkVars, + onSelect, +}: { + isActive: boolean; + lightName: SyntaxThemeName; + lightVars: ThemePreviewVars | null; + darkVars: ThemePreviewVars | null; + onSelect: () => void; +}) { + return ( + + ); +} + +function SingleThemeTile({ + isActive, + name, + vars, + onSelect, +}: { + isActive: boolean; + name: SyntaxThemeName; + vars: ThemePreviewVars | null; + onSelect: () => void; +}) { + return ( + + ); +} + function ThemeSettingsCard() { const { setTheme, - themeName, selectedThemeName, isDark, accentColor, setAccentColor, - followSystem, setFollowSystem, } = useTheme(); - const [search, setSearch] = useState(""); - const didScrollRef = useRef(false); - const activeRef = (node: HTMLButtonElement | null) => { - if (node && !didScrollRef.current) { - didScrollRef.current = true; - node.scrollIntoView({ block: "center" }); - } + + const previewVarsByTheme = useThemePreviewVars(); + const { pairedLight, lightOnly, darkOnly } = useThemeCategories(); + + const getVars = (name: SyntaxThemeName) => + withAccentPreviewVars( + previewVarsByTheme[name] ?? getThemeFallbackPreviewVars(name), + accentColor, + ); + + /** Check if a paired theme (by its light member) is the active selection */ + const isPairActive = (lightName: SyntaxThemeName) => { + const darkName = getThemePair(lightName); + return selectedThemeName === lightName || selectedThemeName === darkName; }; - const selectedTheme = selectedThemeName; + const handleSelectPair = (lightName: SyntaxThemeName) => { + // Selecting a paired theme auto-enables follow-system + setTheme(lightName); + setFollowSystem(true); + }; - const filtered = useMemo(() => { - const q = search.toLowerCase().trim(); - if (!q) return SYNTAX_THEMES; - return SYNTAX_THEMES.filter((name) => name.includes(q)); - }, [search]); + const handleSelectSingle = (name: SyntaxThemeName) => { + setTheme(name); + // Single themes don't follow system — they're always one mode + setFollowSystem(false); + }; return (
-
- - setSearch(e.target.value)} - placeholder="Search themes..." - spellCheck={false} - type="text" - value={search} - /> + {/* Section 1: Paired themes (follow system) */} +
+

+ Adapts to system +

+

+ Automatically switches between light and dark with your system + preferences. +

+
+ {pairedLight.map((lightName) => { + const darkName = getThemePair(lightName); + if (!darkName) return null; + return ( + handleSelectPair(lightName)} + /> + ); + })} +
-
- {filtered.length === 0 ? ( -

- No themes match your search. + {/* Section 2: Light-only themes */} + {lightOnly.length > 0 && ( +

+

Light

+

+ Always uses a light appearance.

- ) : ( - filtered.map((name) => { - const isActive = selectedTheme === name; - const isEffective = themeName === name; - const light = isLightTheme(name); +
+ {lightOnly.map((name) => ( + handleSelectSingle(name)} + vars={getVars(name)} + /> + ))} +
+
+ )} + + {/* Section 3: Dark-only themes */} + {darkOnly.length > 0 && ( +
+

Dark

+

+ Always uses a dark appearance. +

+
+ {darkOnly.map((name) => ( + handleSelectSingle(name)} + vars={getVars(name)} + /> + ))} +
+
+ )} + + {/* Accent color picker */} +
+

Accent color

+
+ {ACCENT_COLORS.map((color) => { + const isNeutral = color.value === NEUTRAL_ACCENT; + const swatchColor = isNeutral + ? "hsl(var(--foreground))" + : color.value; + const checkClassName = + isNeutral && isDark ? "text-black" : "text-white"; return ( ); - }) - )} -
- -
-
-

Accent color

-
- {ACCENT_COLORS.map((color) => { - const isNeutral = color.value === NEUTRAL_ACCENT; - const swatchColor = isNeutral - ? "hsl(var(--foreground))" - : color.value; - const checkClassName = - isNeutral && isDark ? "text-black" : "text-white"; - - return ( - - ); - })} -
+ })}
- -
); diff --git a/desktop/src/shared/theme/ThemePreviewFrame.tsx b/desktop/src/shared/theme/ThemePreviewFrame.tsx new file mode 100644 index 0000000000..260632d5e6 --- /dev/null +++ b/desktop/src/shared/theme/ThemePreviewFrame.tsx @@ -0,0 +1,221 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/cn"; + +export type ThemePreviewVars = Record; + +export const LIGHT_PREVIEW_VARS: ThemePreviewVars = { + "--background": "0 0% 100%", + "--border": "0 0% 89.8%", + "--foreground": "0 0% 9%", + "--muted": "0 0% 96.1%", + "--muted-foreground": "0 0% 45.1%", + "--primary": "0 0% 9%", + "--sidebar-background": "0 0% 98%", + "--sidebar-foreground": "0 0% 9%", +}; + +export const DARK_PREVIEW_VARS: ThemePreviewVars = { + "--background": "0 0% 3.9%", + "--border": "0 0% 14.9%", + "--foreground": "0 0% 98%", + "--muted": "0 0% 14.9%", + "--muted-foreground": "0 0% 63.9%", + "--primary": "0 0% 98%", + "--sidebar-background": "0 0% 0%", + "--sidebar-foreground": "0 0% 98%", +}; + +function hsl(vars: ThemePreviewVars | null, key: string) { + return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]})`; +} + +function hslAlpha(vars: ThemePreviewVars | null, key: string, alpha: number) { + return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]} / ${alpha})`; +} + +function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) { + const clipId = React.useId().replace(/:/g, ""); + const background = hsl(vars, "--background"); + const border = hsl(vars, "--border"); + const foreground = hsl(vars, "--foreground"); + const mutedForeground = hsl(vars, "--muted-foreground"); + const primary = hsl(vars, "--primary"); + const primarySoft = hslAlpha(vars, "--primary", 0.68); + const sidebar = hsl(vars, "--sidebar-background"); + const sidebarForeground = hslAlpha(vars, "--sidebar-foreground", 0.58); + + return ( + + ); +} + +export function ThemePreviewFrame({ + className, + vars, +}: { + className?: string; + vars: ThemePreviewVars | null; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/desktop/src/shared/theme/useThemePreviewVars.ts b/desktop/src/shared/theme/useThemePreviewVars.ts new file mode 100644 index 0000000000..2d2ab35652 --- /dev/null +++ b/desktop/src/shared/theme/useThemePreviewVars.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { createThemeVars } from "./adaptive-theme"; +import { + SYNTAX_THEMES, + type SyntaxThemeName, + extractThemeInfo, + isLightTheme, + loadThemeData, +} from "./theme-loader"; +import { + DARK_PREVIEW_VARS, + LIGHT_PREVIEW_VARS, + type ThemePreviewVars, +} from "./ThemePreviewFrame"; +import { NEUTRAL_ACCENT } from "./ThemeProvider"; +import { hexToHsl } from "./adaptive-theme"; + +export type ThemePreviewVarsByTheme = Partial< + Record +>; + +let themePreviewVarsCache: ThemePreviewVarsByTheme | null = null; +let themePreviewVarsPromise: Promise | null = null; + +async function loadThemePreviewVars(name: SyntaxThemeName) { + const themeData = await loadThemeData(name); + const info = extractThemeInfo(name, themeData); + const { vars } = createThemeVars(info.bg, info.fg, info.comment, { + added: info.added, + deleted: info.deleted, + modified: info.modified, + }); + return [name, vars] as const; +} + +export function preloadThemePreviewVars() { + if (themePreviewVarsCache) { + return Promise.resolve(themePreviewVarsCache); + } + + if (!themePreviewVarsPromise) { + themePreviewVarsPromise = Promise.all( + SYNTAX_THEMES.map((name) => loadThemePreviewVars(name)), + ) + .then((entries) => { + const previewVars = Object.fromEntries( + entries, + ) as ThemePreviewVarsByTheme; + themePreviewVarsCache = previewVars; + return previewVars; + }) + .catch((error) => { + themePreviewVarsPromise = null; + throw error; + }); + } + + return themePreviewVarsPromise; +} + +export function useThemePreviewVars() { + const [previewVarsByTheme, setPreviewVarsByTheme] = + useState(() => themePreviewVarsCache ?? {}); + + useEffect(() => { + let canceled = false; + + void preloadThemePreviewVars() + .then((previewVars) => { + if (!canceled) { + setPreviewVarsByTheme(previewVars); + } + }) + .catch(() => { + if (!canceled) { + setPreviewVarsByTheme({}); + } + }); + + return () => { + canceled = true; + }; + }, []); + + return previewVarsByTheme; +} + +export function getThemeFallbackPreviewVars(name: SyntaxThemeName) { + return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS; +} + +export function withAccentPreviewVars( + vars: ThemePreviewVars | null, + accentColor: string, +): ThemePreviewVars | null { + if (!vars) { + return null; + } + + if (accentColor === NEUTRAL_ACCENT) { + return { + ...vars, + "--primary": vars["--foreground"], + "--primary-foreground": vars["--background"], + }; + } + + return { + ...vars, + "--primary": hexToHsl(accentColor), + }; +}