diff --git a/.gitignore b/.gitignore index 2632e0b..92473db 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ public/vs *.sw? # Agent -.claude \ No newline at end of file +.claude +/docs/superpowers \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index eab6a05..5cfbb4e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,9 +6,10 @@ @custom-variant dark (&:is(.dark *)); :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; + --font-sans: var(--font-jetbrains-mono, monospace); + font-family: var(--font-sans); + font-size: var(--font-size-base); + line-height: var(--density-line-height); font-weight: 400; font-synthesis: none; @@ -48,6 +49,12 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + --font-code: var(--font-jetbrains-mono, monospace); + --font-size-base: 16px; + --font-size-code: 14px; + --density-padding: 0.5rem; + --density-gap: 0.5rem; + --density-line-height: 1.5; } .dark { @@ -117,6 +124,7 @@ @theme inline { --font-sans: var(--font-sans); + --font-code: var(--font-code); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -170,6 +178,14 @@ } } +code, +pre, +.font-mono, +[data-streamdown="code-block-body"] { + font-family: var(--font-code); + font-size: var(--font-size-code); +} + /* Streamdown code blocks: dark mode via shiki dual-theme CSS variables */ .dark [data-streamdown="code-block-body"] { background-color: var(--shiki-dark-bg, var(--sdm-bg, transparent)) !important; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cfc1b60..ea351e6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,16 +1,21 @@ import type { Metadata } from "next" import "./globals.css" -import { JetBrains_Mono } from "next/font/google" +import { Inter, JetBrains_Mono } from "next/font/google" import { NextIntlClientProvider } from "next-intl" import { AppI18nProvider } from "@/components/i18n-provider" import { getMessagesForLocale } from "@/i18n/messages" import { resolveRequestLocale } from "@/i18n/resolve-request-locale" import { ThemeProvider } from "@/components/theme-provider" import { toIntlLocale } from "@/lib/i18n" +import { AppearanceProvider } from "@/lib/appearance/use-appearance" +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}) const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], - variable: "--font-sans", + variable: "--font-jetbrains-mono", }) export const metadata: Metadata = { @@ -30,7 +35,7 @@ export default async function RootLayout({ return ( @@ -50,7 +55,7 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > - {children} + {children} diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index 1837d0c..7c377c2 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -16,6 +16,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { useAppearance } from "@/lib/appearance/use-appearance" import { cn } from "@/lib/utils" import { CheckIcon, CopyIcon } from "lucide-react" import { @@ -124,32 +125,40 @@ const highlighterCache = new Map< Promise> >() -// Token cache +const MAX_TOKENS_CACHE = 200 const tokensCache = new Map() // Subscribers for async token updates const subscribers = new Map void>>() -const getTokensCacheKey = (code: string, language: BundledLanguage) => { +const getTokensCacheKey = ( + code: string, + language: BundledLanguage, + lightTheme: string, + darkTheme: string +) => { const start = code.slice(0, 100) const end = code.length > 100 ? code.slice(-100) : "" - return `${language}:${code.length}:${start}:${end}` + return `${language}:${lightTheme}:${darkTheme}:${code.length}:${start}:${end}` } const getHighlighter = ( - language: BundledLanguage + language: BundledLanguage, + lightTheme: string, + darkTheme: string ): Promise> => { - const cached = highlighterCache.get(language) + const cacheKey = `${language}:${lightTheme}:${darkTheme}` + const cached = highlighterCache.get(cacheKey) if (cached) { return cached } const highlighterPromise = createHighlighter({ langs: [language], - themes: ["github-light", "github-dark"], + themes: [lightTheme as BundledTheme, darkTheme as BundledTheme], }) - highlighterCache.set(language, highlighterPromise) + highlighterCache.set(cacheKey, highlighterPromise) return highlighterPromise } @@ -173,10 +182,17 @@ const createRawTokens = (code: string): TokenizedCode => ({ export const highlightCode = ( code: string, language: BundledLanguage, + lightTheme: string, + darkTheme: string, // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) callback?: (result: TokenizedCode) => void ): TokenizedCode | null => { - const tokensCacheKey = getTokensCacheKey(code, language) + const tokensCacheKey = getTokensCacheKey( + code, + language, + lightTheme, + darkTheme + ) // Return cached result if available const cached = tokensCache.get(tokensCacheKey) @@ -193,7 +209,7 @@ export const highlightCode = ( } // Start highlighting in background - fire-and-forget async pattern - getHighlighter(language) + getHighlighter(language, lightTheme, darkTheme) // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) .then((highlighter) => { const availableLangs = highlighter.getLoadedLanguages() @@ -202,8 +218,8 @@ export const highlightCode = ( const result = highlighter.codeToTokens(code, { lang: langToUse, themes: { - dark: "github-dark", - light: "github-light", + dark: darkTheme as BundledTheme, + light: lightTheme as BundledTheme, }, }) @@ -213,8 +229,11 @@ export const highlightCode = ( tokens: result.tokens, } - // Cache the result tokensCache.set(tokensCacheKey, tokenized) + if (tokensCache.size > MAX_TOKENS_CACHE) { + const first = tokensCache.keys().next().value + if (first !== undefined) tokensCache.delete(first) + } // Notify all subscribers const subs = subscribers.get(tokensCacheKey) @@ -383,40 +402,52 @@ export const CodeBlockContent = ({ language: BundledLanguage showLineNumbers?: boolean }) => { + const { settings } = useAppearance() + const lightTheme = settings.codeThemeLight + const darkTheme = settings.codeThemeDark + // Memoized raw tokens for immediate display const rawTokens = useMemo(() => createRawTokens(code), [code]) // Synchronous cached-or-raw value, recomputed when code/language changes const syncTokenized = useMemo( - () => highlightCode(code, language) ?? rawTokens, - [code, language, rawTokens] + () => highlightCode(code, language, lightTheme, darkTheme) ?? rawTokens, + [code, language, lightTheme, darkTheme, rawTokens] ) - // Async highlighted result, tagged with its source code/language const [asyncState, setAsyncState] = useState<{ code: string language: string + lightTheme: string + darkTheme: string tokenized: TokenizedCode } | null>(null) useEffect(() => { let cancelled = false - // Subscribe to async highlighting result - highlightCode(code, language, (result) => { + highlightCode(code, language, lightTheme, darkTheme, (result) => { if (!cancelled) { - setAsyncState({ code, language, tokenized: result }) + setAsyncState({ + code, + language, + lightTheme, + darkTheme, + tokenized: result, + }) } }) return () => { cancelled = true } - }, [code, language]) + }, [code, language, lightTheme, darkTheme]) - // Use async result only if it matches current code/language const tokenized = - asyncState?.code === code && asyncState?.language === language + asyncState?.code === code && + asyncState?.language === language && + asyncState?.lightTheme === lightTheme && + asyncState?.darkTheme === darkTheme ? asyncState.tokenized : syncTokenized diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index 22c18e1..4928c71 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -1,8 +1,37 @@ "use client" -import { Monitor, Moon, Sun } from "lucide-react" +import { + Check, + Monitor, + Moon, + Minus, + Plus, + Sun, + Type, + Palette, + Terminal, + Accessibility, +} from "lucide-react" import { useTranslations } from "next-intl" import { useTheme } from "next-themes" +import { cn } from "@/lib/utils" +import { useAppearance } from "@/lib/appearance/use-appearance" +import { + ACCENT_PRESETS, + UI_FONT_PRESETS, + CODE_FONT_PRESETS, + CODE_THEME_LIGHT_OPTIONS, + CODE_THEME_DARK_OPTIONS, + TERMINAL_SCHEME_OPTIONS, +} from "@/lib/appearance/constants" +import type { + AccentColor, + AppearanceSettings, + CodeThemeLight, + CodeThemeDark, + TerminalScheme, + InterfaceDensity, +} from "@/lib/appearance/types" import { Select, SelectContent, @@ -13,9 +42,45 @@ import { type ThemeMode = "system" | "light" | "dark" +function FontSizeInput({ + value, + onChange, + min, + max, +}: { + value: number + onChange: (v: number) => void + min: number + max: number +}) { + return ( +
+ + {value}px + +
+ ) +} + export function AppearanceSettings() { const t = useTranslations("AppearanceSettings") const { theme, resolvedTheme, setTheme } = useTheme() + const { settings, update } = useAppearance() + const resolvedThemeLabel = resolvedTheme === "dark" ? t("resolvedTheme.dark") @@ -26,23 +91,24 @@ export function AppearanceSettings() { return (
+ {/* Section 1: Theme */}

{t("sectionTitle")}

-

{t("sectionDescription")}

+ {/* Theme mode */}
update("uiFont", v)} + > + + + + + {UI_FONT_PRESETS.map((p) => ( + + {p.label} + + ))} + + +
+ + {/* Code Font */} +
+ + +
+ + {/* Font Sizes */} +
+
+ + update("uiFontSize", v)} + /> +
+
+ + update("codeFontSize", v)} + /> +
+
+
+ + {/* Section 3: Editor */} +
+
+ +

{t("editorSection")}

+
+

+ {t("editorSectionDesc")} +

+ +
+
+ + +
+
+ + +
+
+
+ + {/* Section 4: Terminal */} +
+
+ +

{t("terminalSection")}

+
+

+ {t("terminalSectionDesc")} +

+ +
+ + +
+
+ + {/* Section 5: Accessibility */} +
+
+ +

+ {t("accessibilitySection")} +

+
+

+ {t("accessibilitySectionDesc")} +

+ + {/* Density */} +
+ + +
+ + {/* Reduce Motion */} +
+ + +
diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index d5f9bfd..5091e68 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -10,58 +10,9 @@ import { } from "@/lib/api" import type { TerminalEvent } from "@/lib/types" import type { ITheme } from "@xterm/xterm" - -const DARK_THEME: ITheme = { - background: "#1a1a1a", - foreground: "#e0e0e0", - cursor: "#e0e0e0", - cursorAccent: "#1a1a1a", - selectionBackground: "#444444", - black: "#1a1a1a", - red: "#f87171", - green: "#4ade80", - yellow: "#facc15", - blue: "#60a5fa", - magenta: "#c084fc", - cyan: "#22d3ee", - white: "#e0e0e0", - brightBlack: "#737373", - brightRed: "#fca5a5", - brightGreen: "#86efac", - brightYellow: "#fde68a", - brightBlue: "#93c5fd", - brightMagenta: "#d8b4fe", - brightCyan: "#67e8f9", - brightWhite: "#ffffff", -} - -const LIGHT_THEME: ITheme = { - background: "#ffffff", - foreground: "#1a1a1a", - cursor: "#1a1a1a", - cursorAccent: "#ffffff", - selectionBackground: "#b4d5fe", - black: "#1a1a1a", - red: "#dc2626", - green: "#16a34a", - yellow: "#ca8a04", - blue: "#2563eb", - magenta: "#9333ea", - cyan: "#0891b2", - white: "#e5e5e5", - brightBlack: "#a3a3a3", - brightRed: "#ef4444", - brightGreen: "#22c55e", - brightYellow: "#eab308", - brightBlue: "#3b82f6", - brightMagenta: "#a855f7", - brightCyan: "#06b6d4", - brightWhite: "#ffffff", -} - -function isDarkMode() { - return document.documentElement.classList.contains("dark") -} +import { useAppearance } from "@/lib/appearance/use-appearance" +import { TERMINAL_SCHEMES } from "@/lib/appearance/constants" +import type { TerminalScheme } from "@/lib/appearance/types" function resolveBackgroundColor( element: HTMLElement | null | undefined @@ -77,11 +28,13 @@ function resolveBackgroundColor( return null } -function getTerminalTheme(container: HTMLDivElement | null): ITheme { - const baseTheme = isDarkMode() ? DARK_THEME : LIGHT_THEME +function getTerminalTheme( + container: HTMLDivElement | null, + scheme: TerminalScheme +): ITheme { + const baseTheme = TERMINAL_SCHEMES[scheme] const background = resolveBackgroundColor(container) if (!background) return baseTheme - return { ...baseTheme, background, @@ -106,6 +59,7 @@ export function TerminalView({ isVisible, onProcessExited, }: TerminalViewProps) { + const { settings } = useAppearance() const containerRef = useRef(null) const fitAddonRef = useRef<{ fit: () => void } | null>(null) const termRef = useRef<{ focus: () => void } | null>(null) @@ -113,6 +67,7 @@ export function TerminalView({ const isActiveRef = useRef(isActive) const isVisibleRef = useRef(isVisible) const onProcessExitedRef = useRef(onProcessExited) + const settingsRef = useRef(settings) const [loading, setLoading] = useState(true) useEffect(() => { @@ -124,6 +79,10 @@ export function TerminalView({ onProcessExitedRef.current = onProcessExited }, [onProcessExited]) + useEffect(() => { + settingsRef.current = settings + }, [settings]) + useEffect(() => { let cancelled = false let cleanup: (() => void) | undefined @@ -140,9 +99,12 @@ export function TerminalView({ const term = new Terminal({ cursorBlink: true, - fontSize: 13, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", - theme: getTerminalTheme(containerRef.current), + fontSize: settingsRef.current.codeFontSize, + fontFamily: settingsRef.current.codeFont, + theme: getTerminalTheme( + containerRef.current, + settingsRef.current.terminalScheme + ), allowProposedApi: true, }) @@ -155,7 +117,10 @@ export function TerminalView({ // Watch class changes for theme switching const themeObserver = new MutationObserver(() => { - term.options.theme = getTerminalTheme(containerRef.current) + term.options.theme = getTerminalTheme( + containerRef.current, + settingsRef.current.terminalScheme + ) }) themeObserver.observe(document.documentElement, { attributes: true, @@ -292,6 +257,21 @@ export function TerminalView({ } }, [isActive, isVisible]) + // React to appearance settings changes on an existing terminal + useEffect(() => { + const term = termRef.current as { + options: { fontSize: number; fontFamily: string; theme: ITheme } + } | null + if (!term?.options) return + term.options.fontSize = settings.codeFontSize + term.options.fontFamily = settings.codeFont + term.options.theme = getTerminalTheme( + containerRef.current, + settings.terminalScheme + ) + fitAddonRef.current?.fit() + }, [settings.codeFontSize, settings.codeFont, settings.terminalScheme]) + return (
= { + blue: { + light: "oklch(0.55 0.2 255)", + dark: "oklch(0.7 0.18 255)", + label: "Blue", + dot: "#3b82f6", + }, + purple: { + light: "oklch(0.5 0.2 290)", + dark: "oklch(0.7 0.18 290)", + label: "Purple", + dot: "#8b5cf6", + }, + pink: { + light: "oklch(0.55 0.2 350)", + dark: "oklch(0.72 0.18 350)", + label: "Pink", + dot: "#ec4899", + }, + red: { + light: "oklch(0.55 0.22 25)", + dark: "oklch(0.7 0.19 25)", + label: "Red", + dot: "#ef4444", + }, + orange: { + light: "oklch(0.6 0.2 55)", + dark: "oklch(0.72 0.18 55)", + label: "Orange", + dot: "#f97316", + }, + yellow: { + light: "oklch(0.6 0.18 85)", + dark: "oklch(0.75 0.16 85)", + label: "Yellow", + dot: "#eab308", + }, + green: { + light: "oklch(0.55 0.18 155)", + dark: "oklch(0.7 0.16 155)", + label: "Green", + dot: "#22c55e", + }, + cyan: { + light: "oklch(0.55 0.15 200)", + dark: "oklch(0.72 0.13 200)", + label: "Cyan", + dot: "#06b6d4", + }, + graphite: { + light: "oklch(0.4 0 0)", + dark: "oklch(0.7 0 0)", + label: "Graphite", + dot: "#71717a", + }, +} + +// ── UI font presets ──────────────────────────────────────────── +// Google Fonts use var() referencing the CSS variable set by next/font. +// System fonts use raw font-family names. +export const UI_FONT_PRESETS = [ + { value: "system-ui, sans-serif", label: "System Default" }, + { value: "var(--font-inter)", label: "Inter" }, + { value: "var(--font-jetbrains-mono)", label: "JetBrains Mono" }, +] as const + +// ── Code font presets ────────────────────────────────────────── +export const CODE_FONT_PRESETS = [ + { value: "var(--font-jetbrains-mono)", label: "JetBrains Mono" }, + { value: "'Fira Code', monospace", label: "Fira Code" }, + { value: "'Source Code Pro', monospace", label: "Source Code Pro" }, + { value: "Consolas, monospace", label: "Consolas (Windows)" }, + { value: "Menlo, monospace", label: "Menlo (macOS)" }, + { value: "'Courier New', monospace", label: "Courier New" }, + { value: "monospace", label: "monospace" }, +] as const + +export const DENSITY_VALUES: Record< + InterfaceDensity, + { padding: string; gap: string; lineHeight: string } +> = { + compact: { padding: "0.25rem", gap: "0.25rem", lineHeight: "1.4" }, + default: { padding: "0.5rem", gap: "0.5rem", lineHeight: "1.5" }, + spacious: { padding: "0.75rem", gap: "0.75rem", lineHeight: "1.7" }, +} + +export const TERMINAL_SCHEMES: Record = { + default: { + background: "#1a1a1a", + foreground: "#e0e0e0", + cursor: "#e0e0e0", + cursorAccent: "#1a1a1a", + selectionBackground: "#444444", + black: "#1a1a1a", + red: "#f87171", + green: "#4ade80", + yellow: "#facc15", + blue: "#60a5fa", + magenta: "#c084fc", + cyan: "#22d3ee", + white: "#e0e0e0", + brightBlack: "#737373", + brightRed: "#fca5a5", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#93c5fd", + brightMagenta: "#d8b4fe", + brightCyan: "#67e8f9", + brightWhite: "#ffffff", + }, + "solarized-dark": { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#073642", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#586e75", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + "solarized-light": { + background: "#fdf6e3", + foreground: "#657b83", + cursor: "#657b83", + cursorAccent: "#fdf6e3", + selectionBackground: "#eee8d5", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#586e75", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + dracula: { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", + }, + "one-dark": { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#528bff", + cursorAccent: "#282c34", + selectionBackground: "#3e4451", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + nord: { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", + }, + monokai: { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f0", + cursorAccent: "#272822", + selectionBackground: "#49483e", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#f4bf75", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", + }, + "github-dark": { + background: "#0d1117", + foreground: "#c9d1d9", + cursor: "#c9d1d9", + cursorAccent: "#0d1117", + selectionBackground: "#264f78", + black: "#484f58", + red: "#ff7b72", + green: "#3fb950", + yellow: "#d29922", + blue: "#58a6ff", + magenta: "#bc8cff", + cyan: "#39c5cf", + white: "#b1bac4", + brightBlack: "#6e7681", + brightRed: "#ffa198", + brightGreen: "#56d364", + brightYellow: "#e3b341", + brightBlue: "#79c0ff", + brightMagenta: "#d2a8ff", + brightCyan: "#56d4dd", + brightWhite: "#f0f6fc", + }, + "github-light": { + background: "#ffffff", + foreground: "#24292f", + cursor: "#24292f", + cursorAccent: "#ffffff", + selectionBackground: "#accef7", + black: "#24292f", + red: "#cf222e", + green: "#116329", + yellow: "#4d2d00", + blue: "#0969da", + magenta: "#8250df", + cyan: "#1b7c83", + white: "#6e7781", + brightBlack: "#57606a", + brightRed: "#a40e26", + brightGreen: "#1a7f37", + brightYellow: "#633c01", + brightBlue: "#218bff", + brightMagenta: "#a475f9", + brightCyan: "#3192aa", + brightWhite: "#8c959f", + }, +} + +export const CODE_THEME_LIGHT_OPTIONS = [ + { value: "github-light", label: "GitHub Light" }, + { value: "vitesse-light", label: "Vitesse Light" }, + { value: "min-light", label: "Min Light" }, +] as const + +export const CODE_THEME_DARK_OPTIONS = [ + { value: "github-dark", label: "GitHub Dark" }, + { value: "vitesse-dark", label: "Vitesse Dark" }, + { value: "one-dark-pro", label: "One Dark Pro" }, + { value: "dracula", label: "Dracula" }, +] as const + +export const TERMINAL_SCHEME_OPTIONS = [ + { value: "default", label: "Default" }, + { value: "solarized-dark", label: "Solarized Dark" }, + { value: "solarized-light", label: "Solarized Light" }, + { value: "dracula", label: "Dracula" }, + { value: "one-dark", label: "One Dark" }, + { value: "nord", label: "Nord" }, + { value: "monokai", label: "Monokai" }, + { value: "github-dark", label: "GitHub Dark" }, + { value: "github-light", label: "GitHub Light" }, +] as const + +export const DEFAULT_APPEARANCE: AppearanceSettings = { + accentColor: "blue", + uiFont: "var(--font-jetbrains-mono)", + codeFont: "var(--font-jetbrains-mono)", + uiFontSize: 16, + codeFontSize: 14, + codeThemeLight: "github-light", + codeThemeDark: "github-dark", + terminalScheme: "default", + density: "default", + reduceMotion: "system", +} + +export const APPEARANCE_STORAGE_KEY = "codeg-appearance" diff --git a/src/lib/appearance/types.ts b/src/lib/appearance/types.ts new file mode 100644 index 0000000..83b0b6c --- /dev/null +++ b/src/lib/appearance/types.ts @@ -0,0 +1,44 @@ +export type AccentColor = + | "blue" + | "purple" + | "pink" + | "red" + | "orange" + | "yellow" + | "green" + | "cyan" + | "graphite" + +export type InterfaceDensity = "compact" | "default" | "spacious" + +export type TerminalScheme = + | "default" + | "solarized-dark" + | "solarized-light" + | "dracula" + | "one-dark" + | "nord" + | "monokai" + | "github-dark" + | "github-light" + +export type CodeThemeLight = "github-light" | "vitesse-light" | "min-light" + +export type CodeThemeDark = + | "github-dark" + | "vitesse-dark" + | "one-dark-pro" + | "dracula" + +export interface AppearanceSettings { + accentColor: AccentColor + uiFont: string + codeFont: string + uiFontSize: number + codeFontSize: number + codeThemeLight: CodeThemeLight + codeThemeDark: CodeThemeDark + terminalScheme: TerminalScheme + density: InterfaceDensity + reduceMotion: "system" | "on" | "off" +} diff --git a/src/lib/appearance/use-appearance.tsx b/src/lib/appearance/use-appearance.tsx new file mode 100644 index 0000000..70a92f4 --- /dev/null +++ b/src/lib/appearance/use-appearance.tsx @@ -0,0 +1,138 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" +import type { ReactNode } from "react" +import type { AppearanceSettings } from "./types" +import { + ACCENT_PRESETS, + APPEARANCE_STORAGE_KEY, + DEFAULT_APPEARANCE, + DENSITY_VALUES, +} from "./constants" + +interface AppearanceContextType { + settings: AppearanceSettings + update: ( + key: K, + value: AppearanceSettings[K] + ) => void +} + +const AppearanceContext = createContext({ + settings: DEFAULT_APPEARANCE, + update: () => {}, +}) + +const reducedMotionQuery = + typeof window !== "undefined" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : null + +function loadSettings(): AppearanceSettings { + if (typeof window === "undefined") return DEFAULT_APPEARANCE + try { + const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY) + if (!raw) return DEFAULT_APPEARANCE + return { ...DEFAULT_APPEARANCE, ...JSON.parse(raw) } + } catch { + return DEFAULT_APPEARANCE + } +} + +function saveSettings(settings: AppearanceSettings) { + localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(settings)) +} + +function applyAccentToDOM(settings: AppearanceSettings) { + const root = document.documentElement.style + const isDark = document.documentElement.classList.contains("dark") + const accent = ACCENT_PRESETS[settings.accentColor] + const v = isDark ? accent.dark : accent.light + root.setProperty("--primary", v) + root.setProperty("--ring", v) + root.setProperty("--sidebar-primary", v) +} + +function applyToDOM(settings: AppearanceSettings) { + const root = document.documentElement.style + + applyAccentToDOM(settings) + + root.setProperty("--font-sans", settings.uiFont) + root.setProperty("--font-code", settings.codeFont) + root.setProperty("--font-size-base", `${settings.uiFontSize}px`) + root.setProperty("--font-size-code", `${settings.codeFontSize}px`) + + const density = DENSITY_VALUES[settings.density] + root.setProperty("--density-padding", density.padding) + root.setProperty("--density-gap", density.gap) + root.setProperty("--density-line-height", density.lineHeight) + + const prefersReduced = reducedMotionQuery?.matches ?? false + const shouldReduce = + settings.reduceMotion === "on" || + (settings.reduceMotion === "system" && prefersReduced) + root.setProperty("--transition-duration", shouldReduce ? "0s" : "") + root.setProperty("--animation-duration", shouldReduce ? "0s" : "") +} + +export function AppearanceProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(loadSettings) + + useEffect(() => { + applyToDOM(settings) + + // Only accent color depends on dark/light class + const observer = new MutationObserver(() => applyAccentToDOM(settings)) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + return () => observer.disconnect() + }, [settings]) + + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key === APPEARANCE_STORAGE_KEY && e.newValue) { + try { + setSettings({ ...DEFAULT_APPEARANCE, ...JSON.parse(e.newValue) }) + } catch { + // ignore + } + } + } + window.addEventListener("storage", handler) + return () => window.removeEventListener("storage", handler) + }, []) + + const update = useCallback( + ( + key: K, + value: AppearanceSettings[K] + ) => { + setSettings((prev) => { + if (prev[key] === value) return prev + const next = { ...prev, [key]: value } + saveSettings(next) + return next + }) + }, + [] + ) + + return ( + + {children} + + ) +} + +export function useAppearance() { + return useContext(AppearanceContext) +}