From a1c8e1a1f95142c9e3de6cf8b190015273f95145 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 31 May 2026 16:09:01 +0800 Subject: [PATCH] Add appearance settings for theme, fonts, and syntax highlighting (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle --- docs/unistyles.md | 14 + packages/app/e2e/helpers/settings.ts | 7 +- packages/app/e2e/settings-navigation.spec.ts | 5 + packages/app/src/app/_layout.tsx | 22 + packages/app/src/components/diff-viewer.tsx | 6 +- .../app/src/components/file-explorer-pane.tsx | 3 +- packages/app/src/components/file-pane.tsx | 8 +- .../src/components/highlighted-code-block.tsx | 2 + .../src/components/highlighted-content.tsx | 10 +- packages/app/src/components/message.tsx | 12 +- .../components/provider-diagnostic-sheet.tsx | 22 +- .../components/terminal-emulator.native.tsx | 20 + .../app/src/components/terminal-emulator.tsx | 14 + packages/app/src/components/terminal-pane.tsx | 6 + .../app/src/components/tool-call-details.tsx | 34 +- packages/app/src/constants/theme.ts | 27 - .../components/desktop-updates-section.tsx | 5 +- packages/app/src/git/diff-pane.tsx | 12 +- packages/app/src/hooks/use-settings/index.ts | 31 + .../src/hooks/use-settings/storage.test.ts | 166 ++++- .../app/src/hooks/use-settings/storage.ts | 79 +++ packages/app/src/panels/setup-panel.tsx | 8 +- packages/app/src/screens/settings-screen.tsx | 134 +--- .../appearance/appearance-preview.tsx | 211 +++++++ .../appearance/appearance-section.tsx | 588 ++++++++++++++++++ .../appearance/apply-appearance.test.ts | 176 ++++++ .../settings/appearance/apply-appearance.ts | 105 ++++ .../settings/appearance/apply-root-font.ts | 4 + .../appearance/apply-root-font.web.ts | 33 + .../settings/appearance/preview-snippet.ts | 21 + .../app/src/screens/startup-splash-screen.tsx | 12 +- packages/app/src/styles/code-surface.ts | 7 + packages/app/src/styles/markdown-styles.ts | 13 +- packages/app/src/styles/theme.ts | 37 +- .../runtime/terminal-emulator-runtime.test.ts | 27 +- .../runtime/terminal-emulator-runtime.ts | 36 +- .../terminal-emulator-webview-entry.ts | 30 +- .../webview/terminal-emulator-webview-html.ts | 2 +- .../app/src/types/react-native-dataset.d.ts | 14 + packages/app/src/utils/host-routes.ts | 1 + .../highlight/src/__tests__/themes.test.ts | 81 +++ packages/highlight/src/index.ts | 7 + packages/highlight/src/themes.ts | 278 +++++++++ 43 files changed, 2083 insertions(+), 247 deletions(-) create mode 100644 packages/app/src/screens/settings/appearance/appearance-preview.tsx create mode 100644 packages/app/src/screens/settings/appearance/appearance-section.tsx create mode 100644 packages/app/src/screens/settings/appearance/apply-appearance.test.ts create mode 100644 packages/app/src/screens/settings/appearance/apply-appearance.ts create mode 100644 packages/app/src/screens/settings/appearance/apply-root-font.ts create mode 100644 packages/app/src/screens/settings/appearance/apply-root-font.web.ts create mode 100644 packages/app/src/screens/settings/appearance/preview-snippet.ts create mode 100644 packages/app/src/styles/code-surface.ts create mode 100644 packages/app/src/types/react-native-dataset.d.ts create mode 100644 packages/highlight/src/__tests__/themes.test.ts create mode 100644 packages/highlight/src/themes.ts diff --git a/docs/unistyles.md b/docs/unistyles.md index f55a94cf9..a17e22ff0 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -289,6 +289,20 @@ That brief transition is expected with the current storage model. It makes track If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`. +## Runtime Theme Patching For User Preferences + +Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`. + +This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render. + +Gotchas: + +- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop. +- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`). +- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes. +- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry. +- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`. + ## Debugging To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`: diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 0b6ff00b6..6164ee673 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -5,6 +5,7 @@ import { getServerId } from "./server-id"; const SECTION_LABELS = { general: "General", + appearance: "Appearance", shortcuts: "Shortcuts", integrations: "Integrations", permissions: "Permissions", @@ -180,7 +181,11 @@ export async function expectAboutContent(page: Page): Promise { } export async function expectGeneralContent(page: Page): Promise { - await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible(); +} + +export async function expectAppearanceContent(page: Page): Promise { + await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible(); } export async function expectHostLabelDisplayed(page: Page): Promise { diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts index de47e8d46..7aaab7bae 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -25,6 +25,7 @@ import { expectDiagnosticsContent, expectAboutContent, expectGeneralContent, + expectAppearanceContent, } from "./helpers/settings"; test.describe("Settings sidebar navigation", () => { @@ -43,6 +44,10 @@ test.describe("Settings sidebar navigation", () => { await openSettingsSection(page, "general"); await expectSettingsHeader(page, "General"); await expectGeneralContent(page); + + await openSettingsSection(page, "appearance"); + await expectSettingsHeader(page, "Appearance"); + await expectAppearanceContent(page); }); test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => { diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 3affc37ee..76997e8dd 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -73,6 +73,7 @@ import { useHosts, } from "@/runtime/host-runtime"; import { getDaemonStartService } from "@/runtime/daemon-start-service"; +import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"; import { usePanelStore } from "@/stores/panel-store"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; @@ -625,6 +626,27 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { } }, [settingsLoading, settings.theme]); + // Apply font / size / syntax appearance settings on mount and when they change. + // Sibling to the theme effect above; order is irrelevant because both patch all + // six registered theme keys, so the active key is always current. + useEffect(() => { + if (settingsLoading) return; + applyAppearance({ + uiFontFamily: settings.uiFontFamily, + monoFontFamily: settings.monoFontFamily, + uiFontSize: settings.uiFontSize, + codeFontSize: settings.codeFontSize, + syntaxTheme: settings.syntaxTheme, + }); + }, [ + settingsLoading, + settings.uiFontFamily, + settings.monoFontFamily, + settings.uiFontSize, + settings.codeFontSize, + settings.syntaxTheme, + ]); + return ( diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 825c13bf5..371f78e55 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -2,13 +2,13 @@ import React from "react"; import { View, Text, ScrollView as RNScrollView } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; import type { DiffLine } from "@/utils/tool-call-parsers"; import { diffLinePrefix } from "@/utils/diff-highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { getCodeInsets } from "./code-insets"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isWeb } from "@/constants/platform"; const ScrollView = isWeb ? RNScrollView : GHScrollView; @@ -165,7 +165,7 @@ export function DiffViewer({ } const lines = ( - + {keyedDiffLines.map(({ key, line }) => ( ))} @@ -226,7 +226,7 @@ const styles = StyleSheet.create((theme) => { paddingVertical: theme.spacing[1], }, lineText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, ...(isWeb diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 5bbf7bd46..46f6da236 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -14,7 +14,6 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import { SvgXml } from "react-native-svg"; import { @@ -1157,7 +1156,7 @@ const styles = StyleSheet.create((theme) => ({ }, codeText: { color: theme.colors.foreground, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, flexShrink: 0, }, diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 3f6e5e104..315010177 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -11,7 +11,6 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -19,6 +18,7 @@ import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; import { createMarkdownStyles } from "@/styles/markdown-styles"; @@ -172,14 +172,14 @@ const codeLineStyles = StyleSheet.create((theme) => ({ }, gutterText: { color: theme.colors.foreground, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: theme.fontSize.code * 1.45, opacity: 0.4, userSelect: "none", }, lineText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: theme.fontSize.code * 1.45, flex: 1, @@ -296,7 +296,7 @@ function FilePreviewBody({ lineNumber: index + 1, })); const codeLines = ( - + {keyedLines.map(({ key, tokens, lineNumber }) => ( diff --git a/packages/app/src/components/highlighted-content.tsx b/packages/app/src/components/highlighted-content.tsx index 0dc85c369..4beec9f93 100644 --- a/packages/app/src/components/highlighted-content.tsx +++ b/packages/app/src/components/highlighted-content.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; import { isWeb } from "@/constants/platform"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache"; @@ -56,7 +56,7 @@ const GutteredLine = React.memo(function GutteredLine({ export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) { if (startLine === undefined) { return ( - + {lines.map((line) => ( ))} @@ -67,7 +67,7 @@ export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) { const lastLineNumber = startLine + lines.length - 1; const digits = Math.max(2, String(lastLineNumber).length); return ( - + {lines.map((line, index) => ( ))} @@ -81,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({ minHeight: CODE_LINE_HEIGHT, }, gutterText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: CODE_LINE_HEIGHT, color: theme.colors.foregroundMuted, @@ -90,7 +90,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, lineText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: CODE_LINE_HEIGHT, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index f4e73aa82..8269d2625 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -62,7 +62,7 @@ import Animated, { } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { createMarkdownStyles } from "@/styles/markdown-styles"; -import { Fonts } from "@/constants/theme"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; @@ -1860,13 +1860,13 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[2], }, headerLabel: { - fontFamily: Fonts.sans, + fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, text: { - fontFamily: Fonts.sans, + fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, lineHeight: 22, color: theme.colors.foreground, @@ -1976,7 +1976,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({ metadataText: { color: theme.colors.foreground, fontSize: theme.fontSize.code, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, lineHeight: 16, }, })); @@ -2072,7 +2072,7 @@ export const ActivityLog = memo(function ActivityLog({ {isExpanded && metadata && ( - + {JSON.stringify(metadata, null, 2)} @@ -2108,7 +2108,7 @@ const compactionStylesheet = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, text: { - fontFamily: Fonts.sans, + fontFamily: theme.fontFamily.ui, fontSize: 13, color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index cac0a20dc..79abc2487 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -17,7 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { isWeb } from "@/constants/platform"; -import { Fonts } from "@/constants/theme"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; @@ -52,7 +52,12 @@ function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) { {model.label} - + {model.id} {model.description ? ( @@ -89,7 +94,12 @@ function CustomModelRow({ {model.label} - + {model.id} @@ -307,7 +317,7 @@ function DiagnosticSubSheet({ body = ( - + {diagnostic} @@ -607,7 +617,7 @@ const sheetStyles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, monoHint: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foregroundMuted, flexShrink: 0, @@ -716,7 +726,7 @@ const sheetStyles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[4], }, codeText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: 18, diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 3383017f9..ec1c41fdd 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -45,6 +45,8 @@ interface TerminalEmulatorProps { testId?: string; xtermTheme?: ITheme; scrollbackLines: number; + fontFamily?: string; + fontSize?: number; swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; @@ -81,6 +83,8 @@ type BridgeInboundMessage = initialSnapshot: TerminalState | null; scrollbackLines: number; theme: ITheme; + fontFamily?: string; + fontSize?: number; pendingModifiers: PendingTerminalModifiers; swipeGesturesEnabled: boolean; } @@ -93,6 +97,7 @@ type BridgeInboundMessage = | { type: "resize"; streamKey: string; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setScrollback"; streamKey: string; lines: number } + | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } | { @@ -160,6 +165,8 @@ function createMountMessage(input: { initialSnapshot: TerminalState | null; scrollbackLines: number; theme: ITheme; + fontFamily?: string; + fontSize?: number; pendingModifiers: PendingTerminalModifiers; swipeGesturesEnabled: boolean; }): BridgeInboundMessage { @@ -169,6 +176,8 @@ function createMountMessage(input: { initialSnapshot: input.initialSnapshot, scrollbackLines: input.scrollbackLines, theme: input.theme, + fontFamily: input.fontFamily, + fontSize: input.fontSize, pendingModifiers: input.pendingModifiers, swipeGesturesEnabled: input.swipeGesturesEnabled, }; @@ -184,6 +193,8 @@ export default function TerminalEmulator({ cursor: "#e6e6e6", }, scrollbackLines, + fontFamily, + fontSize, swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, @@ -218,6 +229,8 @@ export default function TerminalEmulator({ initialSnapshot, scrollbackLines, theme: xtermTheme, + fontFamily, + fontSize, pendingModifiers, swipeGesturesEnabled, }); @@ -226,6 +239,8 @@ export default function TerminalEmulator({ initialSnapshot, scrollbackLines, theme: xtermTheme, + fontFamily, + fontSize, pendingModifiers, swipeGesturesEnabled, }; @@ -393,6 +408,11 @@ export default function TerminalEmulator({ sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); }, [scrollbackLines, sendToWebView, streamKey]); + useEffect(() => { + if (!mountedStreamKeyRef.current) return; + sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); + }, [fontFamily, fontSize, sendToWebView, streamKey]); + useEffect(() => { if (!mountedStreamKeyRef.current) return; sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 73e011cd4..41553cdeb 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -134,6 +134,8 @@ interface TerminalEmulatorProps { testId?: string; xtermTheme?: ITheme; scrollbackLines: number; + fontFamily?: string; + fontSize?: number; swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; @@ -215,6 +217,8 @@ export default function TerminalEmulator({ cursor: "#e6e6e6", }, scrollbackLines, + fontFamily, + fontSize, swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, @@ -236,8 +240,12 @@ export default function TerminalEmulator({ const hostRef = useRef(null); const runtimeRef = useRef(null); const mountedThemeRef = useRef(xtermTheme); + const fontFamilyRef = useRef(fontFamily); + const fontSizeRef = useRef(fontSize); const scrollbackLinesRef = useRef(scrollbackLines); scrollbackLinesRef.current = scrollbackLines; + fontFamilyRef.current = fontFamily; + fontSizeRef.current = fontSize; const viewportRef = useRef(null); const dragStartOffsetRef = useRef(0); const dragStartClientYRef = useRef(0); @@ -486,6 +494,8 @@ export default function TerminalEmulator({ initialSnapshot: initialSnapshotRef.current, scrollback: scrollbackLinesRef.current, theme: mountedThemeRef.current, + fontFamily: fontFamilyRef.current, + fontSize: fontSizeRef.current, }); onRendererReadyChangeRef.current?.({ streamKey, isReady: true }); @@ -525,6 +535,10 @@ export default function TerminalEmulator({ runtimeRef.current?.setPendingModifiers({ pendingModifiers }); }, [pendingModifiers]); + useEffect(() => { + runtimeRef.current?.setFont({ fontFamily, fontSize }); + }, [fontFamily, fontSize]); + useEffect(() => { if (focusRequestToken <= 0) { return () => {}; diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 628433d6c..27d99145d 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -173,6 +173,10 @@ export function TerminalPane({ const { theme } = useUnistyles(); const { settings } = useAppSettings(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); + const terminalFontFamily = useMemo(() => { + const trimmed = settings.monoFontFamily.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }, [settings.monoFontFamily]); const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); @@ -763,6 +767,8 @@ export function TerminalPane({ testId="terminal-surface" xtermTheme={xtermTheme} scrollbackLines={settings.terminalScrollbackLines} + fontFamily={terminalFontFamily} + fontSize={settings.codeFontSize} swipeGesturesEnabled={swipeGesturesEnabled} initialSnapshot={initialSnapshot} onRendererReadyChange={handleRendererReadyChange} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index e4834e3a3..54f752701 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -8,13 +8,13 @@ import { } from "react-native"; import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache"; import { HighlightedLines } from "./highlighted-content"; import { DiffViewer } from "./diff-viewer"; @@ -179,7 +179,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > - + $ {normalizedCommand} @@ -224,7 +224,7 @@ function WorktreeSetupDetailSection({ style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > - + {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} @@ -383,7 +383,7 @@ function SubAgentDetailSection({ style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > - + {childSessionId ? ( session {childSessionId} @@ -466,7 +466,7 @@ function ScrollableTextSection({ {keyedLines ? ( ) : ( - + {content} )} @@ -498,7 +498,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { showsHorizontalScrollIndicator style={ds.webScrollbarStyle} > - + {result ? `${url}\n\n${result}` : url} @@ -542,7 +542,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ showsHorizontalScrollIndicator style={ds.webScrollbarStyle} > - + {detail.content} @@ -553,7 +553,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ if (detail.filePaths && detail.filePaths.length > 0) { out.push( - + {detail.filePaths.join("\n")} , @@ -562,7 +562,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ if (detail.webResults && detail.webResults.length > 0) { out.push( - + {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} , @@ -571,7 +571,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ if (detail.annotations && detail.annotations.length > 0) { out.push( - + {detail.annotations.join("\n\n")} , @@ -638,7 +638,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod contentContainerStyle={styles.jsonContent} showsHorizontalScrollIndicator={true} > - + {value} @@ -738,7 +738,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles } contentContainerStyle={styles.jsonContent} showsHorizontalScrollIndicator={true} > - + {errorText} @@ -823,7 +823,7 @@ const styles = StyleSheet.create((theme) => { padding: theme.spacing[3], }, plainText: { - fontFamily: Fonts.sans, + fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, color: theme.colors.foreground, lineHeight: 22, @@ -876,7 +876,7 @@ const styles = StyleSheet.create((theme) => { padding: insets.padding, }, scrollText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: 18, @@ -891,7 +891,7 @@ const styles = StyleSheet.create((theme) => { color: theme.colors.foregroundMuted, }, subAgentSessionText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foregroundMuted, lineHeight: 18, @@ -907,13 +907,13 @@ const styles = StyleSheet.create((theme) => { gap: theme.spacing[2], }, subAgentActionTool: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foregroundMuted, lineHeight: 18, }, subAgentActionSummary: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: 18, diff --git a/packages/app/src/constants/theme.ts b/packages/app/src/constants/theme.ts index de2c1afbb..431f15c8a 100644 --- a/packages/app/src/constants/theme.ts +++ b/packages/app/src/constants/theme.ts @@ -3,8 +3,6 @@ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. */ -import { Platform } from "react-native"; - const tintColorLight = "#0a7ea4"; const tintColorDark = "#fff"; @@ -26,28 +24,3 @@ export const Colors = { tabIconSelected: tintColorDark, }, }; - -export const Fonts = Platform.select({ - ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: "system-ui", - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: "ui-serif", - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: "ui-rounded", - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: "ui-monospace", - }, - default: { - sans: "normal", - serif: "serif", - rounded: "normal", - mono: "monospace", - }, - web: { - sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", - serif: "Georgia, 'Times New Roman', serif", - rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", - mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", - }, -}); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 394432cdd..e032a2b27 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -15,6 +15,7 @@ import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon- import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; import { resolveAppVersion } from "@/utils/app-version"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; type DesktopDaemonSettings = DesktopSettings["daemon"]; @@ -133,7 +134,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) > {daemonLogs?.logPath ?? "Log path unavailable"} - + {daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"} @@ -163,7 +164,7 @@ function DaemonCliStatusModal({ snapPoints={CLI_STATUS_MODAL_SNAP_POINTS} > - + {cliStatusOutput ?? ""} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 9e99e3015..e115de412 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -57,8 +57,8 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { DiffScroll } from "@/components/diff-scroll"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { buildSplitDiffRows, @@ -955,7 +955,7 @@ function DiffFileBody({ if (layout === "split") { const rows = buildSplitDiffRows(file); return ( - + + {computedLines.map(({ line, lineNumber, key, reviewTarget }) => ( @@ -1006,7 +1006,7 @@ function DiffFileBody({ const textViewportWidth = scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); return ( - + {computedLines.map(({ line, lineNumber, key, reviewTarget }) => ( @@ -2557,7 +2557,7 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], fontSize: theme.fontSize.code, lineHeight: theme.lineHeight.diff, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, color: theme.colors.foregroundMuted, userSelect: "none", }, @@ -2572,7 +2572,7 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[3], fontSize: theme.fontSize.code, lineHeight: theme.lineHeight.diff, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, color: theme.colors.foreground, userSelect: "text", }, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index f58c44483..8baa326cf 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -14,12 +14,20 @@ import { APP_SETTINGS_QUERY_KEY, DEFAULT_APP_SETTINGS, DEFAULT_CLIENT_SETTINGS, + DEFAULT_CODE_FONT_SIZE, DEFAULT_TERMINAL_SCROLLBACK_LINES, + DEFAULT_UI_FONT_SIZE, + MAX_CODE_FONT_SIZE, MAX_TERMINAL_SCROLLBACK_LINES, + MAX_UI_FONT_SIZE, + MIN_CODE_FONT_SIZE, MIN_TERMINAL_SCROLLBACK_LINES, + MIN_UI_FONT_SIZE, loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, loadSettingsFromStorage as loadSettingsFromStoragePure, + parseClampedFontSize, parseTerminalScrollbackLines, + sanitizeFontFamily, saveAppSettings as saveAppSettingsPure, type AppSettings, type DesktopSettingsBridge, @@ -35,10 +43,18 @@ export { APP_SETTINGS_KEY, DEFAULT_APP_SETTINGS, DEFAULT_CLIENT_SETTINGS, + DEFAULT_CODE_FONT_SIZE, DEFAULT_TERMINAL_SCROLLBACK_LINES, + DEFAULT_UI_FONT_SIZE, + MAX_CODE_FONT_SIZE, MAX_TERMINAL_SCROLLBACK_LINES, + MAX_UI_FONT_SIZE, + MIN_CODE_FONT_SIZE, MIN_TERMINAL_SCROLLBACK_LINES, + MIN_UI_FONT_SIZE, + parseClampedFontSize, parseTerminalScrollbackLines, + sanitizeFontFamily, }; export type { AppSettings, @@ -136,6 +152,21 @@ export function useSettings(): UseSettingsReturn { if (updates.terminalScrollbackLines !== undefined) { appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines; } + if (updates.uiFontFamily !== undefined) { + appUpdates.uiFontFamily = updates.uiFontFamily; + } + if (updates.monoFontFamily !== undefined) { + appUpdates.monoFontFamily = updates.monoFontFamily; + } + if (updates.uiFontSize !== undefined) { + appUpdates.uiFontSize = updates.uiFontSize; + } + if (updates.codeFontSize !== undefined) { + appUpdates.codeFontSize = updates.codeFontSize; + } + if (updates.syntaxTheme !== undefined) { + appUpdates.syntaxTheme = updates.syntaxTheme; + } const promises: Promise[] = []; if (Object.keys(appUpdates).length > 0) { promises.push(appSettings.updateSettings(appUpdates)); diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 9bd2a2904..d7b101c8a 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -4,8 +4,11 @@ import { APP_SETTINGS_KEY, DEFAULT_APP_SETTINGS, DEFAULT_CLIENT_SETTINGS, + DEFAULT_CODE_FONT_SIZE, + DEFAULT_UI_FONT_SIZE, loadAppSettingsFromStorage, loadSettingsFromStorage, + parseClampedFontSize, parseTerminalScrollbackLines, saveAppSettings, type SettingsDeps, @@ -87,10 +90,8 @@ describe("loadAppSettingsFromStorage", () => { const result = await loadAppSettingsFromStorage(deps); expect(result).toEqual({ + ...DEFAULT_CLIENT_SETTINGS, theme: "dark", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, }); expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result)); }); @@ -126,12 +127,8 @@ describe("loadSettingsFromStorage", () => { const result = await loadSettingsFromStorage(deps); expect(result).toEqual({ + ...DEFAULT_APP_SETTINGS, theme: "light", - manageBuiltInDaemon: true, - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - releaseChannel: "stable", }); }); @@ -172,10 +169,8 @@ describe("loadSettingsFromStorage", () => { { manageBuiltInDaemon: false, releaseChannel: "beta" }, ]); expect(result).toEqual({ + ...DEFAULT_APP_SETTINGS, theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, manageBuiltInDaemon: false, releaseChannel: "beta", }); @@ -194,12 +189,8 @@ describe("loadSettingsFromStorage", () => { expect(desktop.migrationsApplied).toEqual([]); expect(result).toEqual({ + ...DEFAULT_APP_SETTINGS, theme: "light", - sendBehavior: "interrupt", - serviceUrlBehavior: "ask", - terminalScrollbackLines: 10_000, - manageBuiltInDaemon: true, - releaseChannel: "stable", }); }); }); @@ -234,3 +225,146 @@ describe("parseTerminalScrollbackLines", () => { expect(parseTerminalScrollbackLines("abc")).toBeNull(); }); }); + +describe("appearance settings", () => { + it("defaults the appearance fields when an old blob omits them", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.uiFontFamily).toBe(""); + expect(result.monoFontFamily).toBe(""); + expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE); + expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); + expect(result.syntaxTheme).toBe("github"); + }); + + it("clamps the UI font size into range and rejects non-numeric values", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 999 }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).uiFontSize).toBe(24); + + const low = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 8 }), + }), + }); + expect((await loadAppSettingsFromStorage(low)).uiFontSize).toBe(11); + + const bogus = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: "abc" }), + }), + }); + expect((await loadAppSettingsFromStorage(bogus)).uiFontSize).toBe(DEFAULT_UI_FONT_SIZE); + }); + + it("clamps the code font size into range and rejects non-numeric values", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 999 }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).codeFontSize).toBe(22); + + const low = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 8 }), + }), + }); + expect((await loadAppSettingsFromStorage(low)).codeFontSize).toBe(9); + + const bogus = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: "abc" }), + }), + }); + expect((await loadAppSettingsFromStorage(bogus)).codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); + }); + + it("trims an accepted font family", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: " Menlo " }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("Menlo"); + }); + + it("keeps an explicit empty font family as the default sentinel", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); + }); + + it("rejects a font family containing CSS-breaking characters", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a;b{c}" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); + }); + + it("rejects an over-length font family", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a".repeat(201) }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); + }); + + it("accepts a known syntax theme id", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "dracula" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("dracula"); + }); + + it("drops a removed syntax theme id back to the default", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "auto" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github"); + }); + + it("drops an unknown syntax theme id back to the default", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "bogus" }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github"); + }); +}); + +describe("parseClampedFontSize", () => { + it("clamps to the bounds and rejects non-numeric strings", () => { + expect(parseClampedFontSize(999, { min: 11, max: 24 })).toBe(24); + expect(parseClampedFontSize(8, { min: 11, max: 24 })).toBe(11); + expect(parseClampedFontSize("15", { min: 11, max: 24 })).toBe(15); + expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 86ea66ba9..65a8200b6 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -1,3 +1,4 @@ +import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight"; import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; @@ -15,12 +16,24 @@ const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app" export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; +export const DEFAULT_UI_FONT_SIZE = 16; // == FONT_SIZE.base +export const MIN_UI_FONT_SIZE = 11; +export const MAX_UI_FONT_SIZE = 24; +export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code +export const MIN_CODE_FONT_SIZE = 9; +export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe +export const MAX_FONT_FAMILY_LENGTH = 200; export interface AppSettings { theme: ThemeName | "auto"; sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; terminalScrollbackLines: number; + uiFontFamily: string; // "" = platform default UI stack + monoFontFamily: string; // "" = platform default mono stack + uiFontSize: number; // clamped px, default 16 + codeFontSize: number; // clamped px, default 12 + syntaxTheme: SyntaxThemeId; // default "auto" } export interface Settings extends AppSettings { @@ -33,6 +46,11 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sendBehavior: "interrupt", serviceUrlBehavior: "ask", terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, + uiFontFamily: "", + monoFontFamily: "", + uiFontSize: DEFAULT_UI_FONT_SIZE, + codeFontSize: DEFAULT_CODE_FONT_SIZE, + syntaxTheme: "github", }; export const DEFAULT_APP_SETTINGS: Settings = { @@ -144,6 +162,31 @@ function pickAppSettings(stored: Partial): Partial { if (terminalScrollbackLines !== null) { result.terminalScrollbackLines = terminalScrollbackLines; } + const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily); + if (uiFontFamily !== null) { + result.uiFontFamily = uiFontFamily; + } + const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily); + if (monoFontFamily !== null) { + result.monoFontFamily = monoFontFamily; + } + const uiFontSize = parseClampedFontSize(stored.uiFontSize, { + min: MIN_UI_FONT_SIZE, + max: MAX_UI_FONT_SIZE, + }); + if (uiFontSize !== null) { + result.uiFontSize = uiFontSize; + } + const codeFontSize = parseClampedFontSize(stored.codeFontSize, { + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + }); + if (codeFontSize !== null) { + result.codeFontSize = codeFontSize; + } + if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) { + result.syntaxTheme = stored.syntaxTheme; + } return result; } @@ -171,6 +214,42 @@ export function parseTerminalScrollbackLines(value: unknown): number | null { ); } +export function parseClampedFontSize( + value: unknown, + bounds: { min: number; max: number }, +): number | null { + let numericValue = NaN; + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string" && value.trim().length > 0) { + numericValue = Number(value); + } + if (!Number.isFinite(numericValue)) { + return null; + } + return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue))); +} + +export function sanitizeFontFamily(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + return ""; // explicit empty = default + } + if (trimmed.length > MAX_FONT_FAMILY_LENGTH) { + return null; + } + if (/[;{}<>]/.test(trimmed)) { + return null; // would break the web CSS font-family declaration + } + if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) { + return null; // control chars would corrupt the font-family string + } + return trimmed; // quotes/commas are legit in stacks +} + async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{ manageBuiltInDaemon?: boolean; releaseChannel?: ReleaseChannel; diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 7bedb1100..64829d6c3 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -10,10 +10,10 @@ import { } from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Fonts } from "@/constants/theme"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import type { Theme } from "@/styles/theme"; import { useWorkspaceSetupStore, @@ -350,7 +350,7 @@ function SetupCommandRow({ accessible accessibilityLabel="Workspace setup log" > - + {processedLog} @@ -404,7 +404,7 @@ function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: s accessible accessibilityLabel="Workspace setup log" > - + {log} @@ -540,7 +540,7 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], }, logText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: 20, color: theme.colors.foreground, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index d17a31e0b..a1acac1e8 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -24,11 +24,10 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Buffer } from "buffer"; import { ArrowLeft, - Sun, - Moon, - Monitor, ChevronDown, + Monitor, Settings, + Palette, Server, Network, Workflow, @@ -46,6 +45,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { SettingsSection } from "@/screens/settings/settings-section"; +import { AppearanceSection } from "@/screens/settings/appearance/appearance-section"; import { useAppSettings, useSettings, @@ -55,7 +55,6 @@ import { type ServiceUrlBehavior, type Settings as EffectiveSettings, } from "@/hooks/use-settings"; -import { THEME_SWATCHES } from "@/styles/theme"; import { getHostRuntimeStore, isHostRuntimeConnected, @@ -80,7 +79,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; @@ -134,6 +132,7 @@ interface SidebarSectionItem { const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", label: "General", icon: Settings }, + { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true }, { id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true }, @@ -155,45 +154,9 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ ]; // --------------------------------------------------------------------------- -// Theme helpers (General section) +// Trigger + sidebar style helpers // --------------------------------------------------------------------------- -function ThemeIcon({ - theme, - size, - color, -}: { - theme: AppSettings["theme"]; - size: number; - color: string; -}) { - switch (theme) { - case "light": - return ; - case "dark": - return ; - case "auto": - return ; - default: - return ; - } -} - -function ThemeSwatch({ color, size }: { color: string; size: number }) { - const swatchStyle = useMemo( - () => ({ - width: size, - height: size, - borderRadius: size / 2, - backgroundColor: color, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.15)", - }), - [color, size], - ); - return ; -} - function themeTriggerStyle({ pressed }: PressableStateCallbackType) { return [styles.themeTrigger, pressed && { opacity: 0.85 }]; } @@ -210,16 +173,6 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho ]; } -const THEME_LABELS: Record = { - light: "Light", - dark: "Dark", - zinc: "Zinc", - midnight: "Midnight", - claude: "Claude", - ghostty: "Ghostty", - auto: "System", -}; - const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const SEND_BEHAVIOR_OPTIONS = [ @@ -247,41 +200,11 @@ const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "ext interface GeneralSectionProps { settings: AppSettings; isDesktopApp: boolean; - handleThemeChange: (theme: AppSettings["theme"]) => void; handleSendBehaviorChange: (behavior: SendBehavior) => void; handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void; handleTerminalScrollbackLinesChange: (lines: number) => void; } -interface ThemeMenuItemProps { - themeValue: AppSettings["theme"]; - selected: boolean; - iconSize: number; - iconColor: string; - onChange: (theme: AppSettings["theme"]) => void; -} - -function ThemeMenuItem({ - themeValue, - selected, - iconSize, - iconColor, - onChange, -}: ThemeMenuItemProps) { - const handleSelect = useCallback(() => { - onChange(themeValue); - }, [onChange, themeValue]); - const leading = useMemo( - () => , - [themeValue, iconSize, iconColor], - ); - return ( - - {THEME_LABELS[themeValue]} - - ); -} - interface ServiceUrlBehaviorMenuItemProps { value: ServiceUrlBehavior; selected: boolean; @@ -306,13 +229,11 @@ function ServiceUrlBehaviorMenuItem({ function GeneralSection({ settings, isDesktopApp, - handleThemeChange, handleSendBehaviorChange, handleServiceUrlBehaviorChange, handleTerminalScrollbackLinesChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); - const iconSize = theme.iconSize.md; const iconColor = theme.colors.foregroundMuted; const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( String(settings.terminalScrollbackLines), @@ -343,41 +264,6 @@ function GeneralSection({ - - Theme - - - - - {THEME_LABELS[settings.theme]} - - - - {(["light", "dark", "auto"] as const).map((t) => ( - - ))} - - {(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => ( - - ))} - - - - Default send @@ -1217,13 +1103,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { return localServerId ?? sortedHosts[0]?.serverId ?? null; }, [view, localServerId, sortedHosts]); - const handleThemeChange = useCallback( - (nextTheme: AppSettings["theme"]) => { - void updateSettings({ theme: nextTheme }); - }, - [updateSettings], - ); - const handleSendBehaviorChange = useCallback( (behavior: SendBehavior) => { void updateSettings({ sendBehavior: behavior }); @@ -1446,12 +1325,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { ); + case "appearance": + return ; case "shortcuts": return isDesktopApp ? : null; case "integrations": diff --git a/packages/app/src/screens/settings/appearance/appearance-preview.tsx b/packages/app/src/screens/settings/appearance/appearance-preview.tsx new file mode 100644 index 000000000..aff474f73 --- /dev/null +++ b/packages/app/src/screens/settings/appearance/appearance-preview.tsx @@ -0,0 +1,211 @@ +import { useMemo } from "react"; +import { Text, View, type TextStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { HighlightToken } from "@getpaseo/highlight"; +import { isWeb } from "@/constants/platform"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import { DEFAULT_MONO_FONT_STACK } from "@/styles/theme"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { tokenizeToLines } from "@/utils/highlight-cache"; +import { CHANGED_LINE_INDICES, PREVIEW_AFTER, PREVIEW_BEFORE } from "./preview-snippet"; + +// Snippets are TypeScript; the cache keys grammar selection off the extension. +const PREVIEW_EXTENSION = "ts"; + +// GitHub diff tints, matching git/diff-pane.tsx (addLineContainer / +// removeLineContainer). Hardcoded rgba is the documented diff exception to the +// "no raw hex outside the palette" rule (docs/design.md §13). +const REMOVED_TINT = "rgba(248, 81, 73, 0.1)"; +const ADDED_TINT = "rgba(46, 160, 67, 0.15)"; + +// Zero-width space keeps blank lines at full line height. +const ZERO_WIDTH = "​"; + +type RowType = "context" | "add" | "remove"; + +interface PreviewOverrides { + monoFontFamily?: string; + codeFontSize?: number; +} + +interface AppearancePreviewProps { + // Live draft values for the code font applied as inline overrides on top of the + // themed styles (the while-typing path). Absent/empty fields fall back to the + // theme value; an explicitly-empty family resolves to the default stack. + overrides?: PreviewOverrides; +} + +function resolveFamilyOverride(value: string | undefined, fallback: string): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? fallback : trimmed; +} + +function resolveSizeOverride(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function buildCodeOverride(overrides: PreviewOverrides | undefined): TextStyle { + if (!overrides) return {}; + const style: TextStyle = {}; + const fontFamily = resolveFamilyOverride(overrides.monoFontFamily, DEFAULT_MONO_FONT_STACK); + if (fontFamily !== undefined) style.fontFamily = fontFamily; + const fontSize = resolveSizeOverride(overrides.codeFontSize); + if (fontSize !== undefined) { + style.fontSize = fontSize; + // Mirror applyAppearance's code line-height coupling so a larger draft size + // doesn't clip while the user is still typing it. + style.lineHeight = Math.round(fontSize * 1.5); + } + // High-churn draft values bypass the Unistyles CSS registry (docs/unistyles.md). + return inlineUnistylesStyle(style); +} + +interface KeyedToken { + key: string; + style: string | null; + text: string; +} + +interface UnifiedRow { + key: string; + type: RowType; + marker: string; + tokens: KeyedToken[] | null; + fallbackText: string; +} + +function makeRow( + key: string, + type: RowType, + marker: string, + text: string, + raw: HighlightToken[] | null, +): UnifiedRow { + const tokens = + raw && raw.length > 0 + ? raw.map((token, index) => ({ + key: `${key}-${index}`, + style: token.style, + text: token.text, + })) + : null; + return { key, type, marker, tokens, fallbackText: text.length > 0 ? text : ZERO_WIDTH }; +} + +// Interleave the before/after snippet into a single unified diff: unchanged lines +// appear once as context; a changed line emits a "-" removed row (from BEFORE) +// followed by a "+" added row (from AFTER). Tokens are precomputed with stable +// keys so the renderer never keys off an array index. +function buildUnifiedRows(): UnifiedRow[] { + const beforeLines = tokenizeToLines(PREVIEW_BEFORE.join("\n"), PREVIEW_EXTENSION); + const afterLines = tokenizeToLines(PREVIEW_AFTER.join("\n"), PREVIEW_EXTENSION); + const rows: UnifiedRow[] = []; + for (let index = 0; index < PREVIEW_BEFORE.length; index += 1) { + if (CHANGED_LINE_INDICES.has(index)) { + rows.push( + makeRow(`r-${index}`, "remove", "- ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null), + ); + rows.push( + makeRow(`a-${index}`, "add", "+ ", PREVIEW_AFTER[index], afterLines?.[index] ?? null), + ); + } else { + rows.push( + makeRow(`c-${index}`, "context", " ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null), + ); + } + } + return rows; +} + +// Marker color follows the diff stat tokens; a single style ref per type keeps it +// off the new-array-as-prop path. The marker is a child of the code , so it +// inherits the mono font + (draft) size and only overrides its color. +function markerStyle(type: RowType) { + if (type === "add") return styles.markerAdd; + if (type === "remove") return styles.markerRemove; + return styles.markerContext; +} + +// Self-contained live preview: a unified diff of a fixed TypeScript snippet in the +// code (mono) font with the selected syntax colors. All themed styling flows +// through StyleSheet.create((theme) => …) so it repaints when +// UnistylesRuntime.updateTheme commits a setting; the optional `overrides` layer +// inline styles for live-while-typing feedback on the code font. +export function AppearancePreview({ overrides }: AppearancePreviewProps) { + const rows = useMemo(() => buildUnifiedRows(), []); + const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]); + const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]); + const addRowStyle = useMemo(() => [styles.row, styles.addRow], []); + const removeRowStyle = useMemo(() => [styles.row, styles.removeRow], []); + + function rowStyle(type: RowType) { + if (type === "add") return addRowStyle; + if (type === "remove") return removeRowStyle; + return styles.row; + } + + return ( + + {rows.map((row) => ( + + + {row.marker} + {row.tokens + ? row.tokens.map((token) => ( + + {token.text} + + )) + : row.fallbackText} + + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + card: { + backgroundColor: theme.colors.surface1, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + paddingVertical: theme.spacing[2], + }, + row: { + paddingHorizontal: theme.spacing[3], + }, + addRow: { + backgroundColor: ADDED_TINT, + }, + removeRow: { + backgroundColor: REMOVED_TINT, + }, + codeLine: { + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + lineHeight: theme.lineHeight.diff, + color: theme.colors.foreground, + ...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal" } : null), + }, + markerContext: { + color: theme.colors.foregroundMuted, + }, + markerAdd: { + color: theme.colors.diffAddition, + }, + markerRemove: { + color: theme.colors.diffDeletion, + }, +})); diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx new file mode 100644 index 000000000..59b1c4371 --- /dev/null +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -0,0 +1,588 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TextInput, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native"; +import { + SYNTAX_THEME_OPTIONS, + type SyntaxThemeId, + type SyntaxThemeOption, +} from "@getpaseo/highlight"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { + MAX_CODE_FONT_SIZE, + MAX_UI_FONT_SIZE, + MIN_CODE_FONT_SIZE, + MIN_UI_FONT_SIZE, + parseClampedFontSize, + sanitizeFontFamily, + useAppSettings, + type AppSettings, +} from "@/hooks/use-settings"; +import { + DEFAULT_MONO_FONT_STACK, + DEFAULT_UI_FONT_STACK, + ICON_SIZE, + THEME_SWATCHES, + type Theme, +} from "@/styles/theme"; +import { settingsStyles } from "@/styles/settings"; +import { AppearancePreview } from "./appearance-preview"; + +// --------------------------------------------------------------------------- +// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no +// useUnistyles). Icon sizes read the static ICON_SIZE token; the appearance +// feature does not scale icons. +// --------------------------------------------------------------------------- + +const ThemedSun = withUnistyles(Sun); +const ThemedMoon = withUnistyles(Moon); +const ThemedMonitor = withUnistyles(Monitor); +const ThemedChevronDown = withUnistyles(ChevronDown); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +// Stored value -> displayed label. `auto` reads as "System" for the app theme. +const THEME_LABELS: Record = { + light: "Light", + dark: "Dark", + zinc: "Zinc", + midnight: "Midnight", + claude: "Claude", + ghostty: "Ghostty", + auto: "System", +}; + +const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"]; +const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [ + "zinc", + "midnight", + "claude", + "ghostty", +]; + +// Platform default stacks can be the bare native tokens ("normal"/"monospace"); +// those read as a bug, so show a human label in the placeholder instead. +const BARE_DEFAULT_STACKS: ReadonlySet = new Set(["normal", "monospace"]); + +function resolveDefaultStackPlaceholder(stack: string): string { + return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack; +} + +const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK); +const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK); + +// Local size string (digits only) -> preview override number. Empty/invalid +// yields undefined so the preview falls back to the committed theme value. +function sizeDraftToOverride(value: string): number | undefined { + if (value.length === 0) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) { + return [styles.trigger, pressed ? styles.triggerPressed : null]; +} + +// --------------------------------------------------------------------------- +// Theme picker +// --------------------------------------------------------------------------- + +interface ThemeLeadingProps { + themeValue: AppSettings["theme"]; +} + +function ThemeLeading({ themeValue }: ThemeLeadingProps) { + switch (themeValue) { + case "light": + return ; + case "dark": + return ; + case "auto": + return ; + default: + return ; + } +} + +interface ThemeSwatchProps { + color: string; +} + +function ThemeSwatch({ color }: ThemeSwatchProps) { + const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]); + return ; +} + +interface ThemeMenuItemProps { + themeValue: AppSettings["theme"]; + selected: boolean; + onChange: (theme: AppSettings["theme"]) => void; +} + +function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) { + const handleSelect = useCallback(() => { + onChange(themeValue); + }, [onChange, themeValue]); + const leading = useMemo(() => , [themeValue]); + return ( + + {THEME_LABELS[themeValue]} + + ); +} + +interface ThemeRowProps { + value: AppSettings["theme"]; + onChange: (theme: AppSettings["theme"]) => void; +} + +function ThemeRow({ value, onChange }: ThemeRowProps) { + return ( + + + Theme + + + + + {THEME_LABELS[value]} + + + + {PRIMARY_THEMES.map((themeValue) => ( + + ))} + + {DARK_VARIANT_THEMES.map((themeValue) => ( + + ))} + + + + ); +} + +// --------------------------------------------------------------------------- +// Fonts: family text fields + numeric size fields (commit on blur/submit) +// --------------------------------------------------------------------------- + +interface FontFamilyRowProps { + title: string; + hint: string; + accessibilityLabel: string; + placeholder: string; + value: string; + draft: string; + withBorder: boolean; + onChangeDraft: (value: string) => void; + onCommit: (value: string) => void; +} + +function FontFamilyRow({ + title, + hint, + accessibilityLabel, + placeholder, + value, + draft, + withBorder, + onChangeDraft, + onCommit, +}: FontFamilyRowProps) { + const handleCommit = useCallback(() => { + onCommit(draft); + }, [draft, onCommit]); + + // Resync from the committed value when it changes elsewhere. + useEffect(() => { + onChangeDraft(value); + // Only resync on external value changes, not on local keystrokes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + return ( + + + {title} + {hint} + + + + ); +} + +interface FontSizeRowProps { + title: string; + accessibilityLabel: string; + draft: string; + onChangeDraft: (value: string) => void; + onCommit: () => void; +} + +function FontSizeRow({ + title, + accessibilityLabel, + draft, + onChangeDraft, + onCommit, +}: FontSizeRowProps) { + return ( + + + {title} + + + + px + + + ); +} + +// --------------------------------------------------------------------------- +// Syntax highlight theme picker (commits immediately) +// --------------------------------------------------------------------------- + +function syntaxLabelForId(id: SyntaxThemeId): string { + const option = SYNTAX_THEME_OPTIONS.find((entry) => entry.id === id); + return option ? option.label : id; +} + +interface SyntaxMenuItemProps { + option: SyntaxThemeOption; + selected: boolean; + onChange: (id: SyntaxThemeId) => void; +} + +function SyntaxMenuItem({ option, selected, onChange }: SyntaxMenuItemProps) { + const handleSelect = useCallback(() => { + onChange(option.id); + }, [onChange, option.id]); + return ( + + {option.label} + + ); +} + +interface SyntaxRowProps { + value: SyntaxThemeId; + onChange: (id: SyntaxThemeId) => void; +} + +function SyntaxRow({ value, onChange }: SyntaxRowProps) { + return ( + + + Highlight theme + Colors for code, independent of the app theme + + + + {syntaxLabelForId(value)} + + + + {SYNTAX_THEME_OPTIONS.map((option) => ( + + ))} + + + + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export function AppearanceSection() { + const { settings, updateSettings } = useAppSettings(); + + const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily); + const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily); + const [uiSizeDraft, setUiSizeDraft] = useState(String(settings.uiFontSize)); + const [codeSizeDraft, setCodeSizeDraft] = useState(String(settings.codeFontSize)); + + // Resync numeric drafts when the committed value changes elsewhere. + useEffect(() => { + setUiSizeDraft(String(settings.uiFontSize)); + }, [settings.uiFontSize]); + useEffect(() => { + setCodeSizeDraft(String(settings.codeFontSize)); + }, [settings.codeFontSize]); + + const handleThemeChange = useCallback( + (theme: AppSettings["theme"]) => { + void updateSettings({ theme }); + }, + [updateSettings], + ); + + const handleSyntaxThemeChange = useCallback( + (syntaxTheme: SyntaxThemeId) => { + void updateSettings({ syntaxTheme }); + }, + [updateSettings], + ); + + const commitUiFontFamily = useCallback( + (value: string) => { + const sanitized = sanitizeFontFamily(value); + if (sanitized === null) { + setUiFontDraft(settings.uiFontFamily); + return; + } + setUiFontDraft(sanitized); + if (sanitized !== settings.uiFontFamily) { + void updateSettings({ uiFontFamily: sanitized }); + } + }, + [settings.uiFontFamily, updateSettings], + ); + + const commitMonoFontFamily = useCallback( + (value: string) => { + const sanitized = sanitizeFontFamily(value); + if (sanitized === null) { + setMonoFontDraft(settings.monoFontFamily); + return; + } + setMonoFontDraft(sanitized); + if (sanitized !== settings.monoFontFamily) { + void updateSettings({ monoFontFamily: sanitized }); + } + }, + [settings.monoFontFamily, updateSettings], + ); + + const handleUiSizeChange = useCallback((value: string) => { + setUiSizeDraft(value.replace(/[^\d]/g, "")); + }, []); + + const handleCodeSizeChange = useCallback((value: string) => { + setCodeSizeDraft(value.replace(/[^\d]/g, "")); + }, []); + + const commitUiSize = useCallback(() => { + const parsed = parseClampedFontSize(uiSizeDraft, { + min: MIN_UI_FONT_SIZE, + max: MAX_UI_FONT_SIZE, + }); + const next = parsed ?? settings.uiFontSize; + setUiSizeDraft(String(next)); + if (next !== settings.uiFontSize) { + void updateSettings({ uiFontSize: next }); + } + }, [settings.uiFontSize, uiSizeDraft, updateSettings]); + + const commitCodeSize = useCallback(() => { + const parsed = parseClampedFontSize(codeSizeDraft, { + min: MIN_CODE_FONT_SIZE, + max: MAX_CODE_FONT_SIZE, + }); + const next = parsed ?? settings.codeFontSize; + setCodeSizeDraft(String(next)); + if (next !== settings.codeFontSize) { + void updateSettings({ codeFontSize: next }); + } + }, [codeSizeDraft, settings.codeFontSize, updateSettings]); + + // Live-while-typing: the in-progress drafts drive the preview without + // committing to the global theme. Empty/invalid fields fall back to the + // theme value inside the preview. + const previewOverrides = useMemo( + () => ({ + monoFontFamily: monoFontDraft, + codeFontSize: sizeDraftToOverride(codeSizeDraft), + }), + [codeSizeDraft, monoFontDraft], + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + preview: { + marginTop: theme.spacing[4], + }, + rowWithBorder: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + }, + trigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + triggerPressed: { + opacity: 0.85, + }, + triggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + swatch: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, + borderRadius: ICON_SIZE.md / 2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + fontFamilyInput: { + flexGrow: 1, + flexShrink: 1, + maxWidth: 280, + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + textAlign: "left", + }, + sizeField: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + sizeInput: { + width: 64, + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + textAlign: "right", + }, + unit: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + placeholderColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts new file mode 100644 index 000000000..7fccd0f5f --- /dev/null +++ b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight"; +import { DEFAULT_UI_FONT_STACK } from "@/styles/theme"; +import { applyAppearance, type AppearanceInput } from "./apply-appearance"; + +// Override the global react-native-unistyles mock (vitest.setup.ts) so that +// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls. +const { updateTheme } = vi.hoisted(() => ({ updateTheme: vi.fn() })); +vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: { updateTheme } })); + +// The six registered Unistyles theme keys, in the order applyAppearance patches them. +const ALL_THEME_KEYS = [ + "light", + "dark", + "darkZinc", + "darkMidnight", + "darkClaude", + "darkGhostty", +] as const; + +// The signature of the updater passed to UnistylesRuntime.updateTheme. +type ThemeUpdater = (theme: FakeTheme) => FakeTheme; + +// The subset of the theme shape the updater reads / spreads. The real Theme type +// is a frozen `as const` literal; the updater only touches these fields. Casting a +// fake of this shape through `unknown` to ThemeUpdater's param is test-only. +interface FakeTheme { + colorScheme: "light" | "dark"; + fontFamily: { ui: string; mono: string }; + fontSize: { + xs: number; + code: number; + sm: number; + base: number; + lg: number; + xl: number; + "2xl": number; + "3xl": number; + "4xl": number; + }; + lineHeight: { diff: number }; + colors: { foreground: string; syntax: Record }; +} + +function makeFakeTheme(): FakeTheme { + return { + colorScheme: "dark", + fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" }, + fontSize: { + xs: 12, + code: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + "2xl": 22, + "3xl": 26, + "4xl": 34, + }, + lineHeight: { diff: 22 }, + colors: { foreground: "#fff", syntax: {} }, + }; +} + +function makeInput(overrides: Partial = {}): AppearanceInput { + return { + uiFontFamily: "", + monoFontFamily: "", + uiFontSize: 16, + codeFontSize: 12, + syntaxTheme: "github", + ...overrides, + }; +} + +// Run a single captured updater (default the first) against a fresh fake theme. +function runCapturedUpdater(call = 0): FakeTheme { + const updater = updateTheme.mock.calls[call]?.[1] as unknown as ThemeUpdater; + return updater(makeFakeTheme()); +} + +describe("applyAppearance", () => { + beforeEach(() => { + updateTheme.mockClear(); + }); + + it("patches every registered Unistyles theme exactly once", () => { + applyAppearance(makeInput()); + + expect(updateTheme).toHaveBeenCalledTimes(6); + expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]); + }); + + it("resolves an empty UI font family to the default stack", () => { + applyAppearance(makeInput({ uiFontFamily: "" })); + + expect(runCapturedUpdater().fontFamily.ui).toBe(DEFAULT_UI_FONT_STACK); + }); + + it("passes a non-empty UI font family through trimmed", () => { + applyAppearance(makeInput({ uiFontFamily: " Menlo " })); + + expect(runCapturedUpdater().fontFamily.ui).toBe("Menlo"); + }); + + it("scales the whole UI ramp proportionally while preserving ratios", () => { + applyAppearance(makeInput({ uiFontSize: 14 })); + + const { fontSize } = runCapturedUpdater(); + // r = 14 / 16 = 0.875 + expect(fontSize.base).toBe(14); // round(16 * 0.875) + expect(fontSize.lg).toBe(16); // round(18 * 0.875) = round(15.75) + expect(fontSize.xs).toBe(11); // round(12 * 0.875) = round(10.5) + expect(fontSize["4xl"]).toBe(30); // round(34 * 0.875) = round(29.75) + }); + + it("derives the UI ramp from the canonical sizes, not the live theme (no compounding)", () => { + applyAppearance(makeInput({ uiFontSize: 14 })); + + // Simulate a theme whose fontSize was already scaled by a prior apply; the + // updater must ignore it and rebuild from the authored FONT_SIZE ramp. + const updater = updateTheme.mock.calls[0]?.[1] as unknown as ThemeUpdater; + const alreadyScaled = makeFakeTheme(); + alreadyScaled.fontSize = { + xs: 4, + code: 4, + sm: 4, + base: 4, + lg: 4, + xl: 4, + "2xl": 4, + "3xl": 4, + "4xl": 4, + }; + + const { fontSize } = updater(alreadyScaled); + expect(fontSize.base).toBe(14); // not 4 * 0.875 — rebuilt from FONT_SIZE + expect(fontSize.lg).toBe(16); + }); + + it("leaves the UI ramp at authored sizes when only the code size changes", () => { + applyAppearance(makeInput({ uiFontSize: 16, codeFontSize: 10 })); + + const { fontSize } = runCapturedUpdater(); + expect(fontSize.base).toBe(16); + expect(fontSize.sm).toBe(14); + expect(fontSize.code).toBe(10); + }); + + it("sets fontSize.code to codeFontSize regardless of the UI font size", () => { + applyAppearance(makeInput({ uiFontSize: 14, codeFontSize: 18 })); + + expect(runCapturedUpdater().fontSize.code).toBe(18); + }); + + it("couples lineHeight.diff to the code font size", () => { + applyAppearance(makeInput({ codeFontSize: 18 })); + + expect(runCapturedUpdater().lineHeight.diff).toBe(Math.round(18 * 1.5)); // 27 + }); + + it("swaps colors.syntax to the resolved palette for the named theme", () => { + applyAppearance(makeInput({ syntaxTheme: "dracula" })); + + const { colors } = runCapturedUpdater(); + expect(colors.syntax).toEqual(resolveSyntaxColors("dracula", "dark")); + }); + + it("resolves a syntax theme using the theme's own color scheme", () => { + applyAppearance(makeInput({ syntaxTheme: "github" })); + + // makeFakeTheme().colorScheme === "dark" -> github resolves to the dark palette. + expect(runCapturedUpdater().colors.syntax).toEqual(darkHighlightColors); + expect(runCapturedUpdater().colors.syntax).toEqual(resolveSyntaxColors("github", "dark")); + }); +}); diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.ts b/packages/app/src/screens/settings/appearance/apply-appearance.ts new file mode 100644 index 000000000..6bc308bb2 --- /dev/null +++ b/packages/app/src/screens/settings/appearance/apply-appearance.ts @@ -0,0 +1,105 @@ +import { UnistylesRuntime } from "react-native-unistyles"; +import { resolveSyntaxColors, type SyntaxThemeId } from "@getpaseo/highlight"; +import { + DEFAULT_UI_FONT_STACK, + DEFAULT_MONO_FONT_STACK, + FONT_SIZE, + type Theme, +} from "@/styles/theme"; +import { applyRootUiFont } from "./apply-root-font"; + +// All six registered Unistyles keys — pinned literal (greppable, type-checked). +// The `as const` element types are exactly `keyof UnistylesThemes`, so each key +// is assignable to `UnistylesRuntime.updateTheme`'s first argument with no cast. +const ALL_THEME_KEYS = [ + "light", + "dark", + "darkZinc", + "darkMidnight", + "darkClaude", + "darkGhostty", +] as const; + +// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor). +const BASE_UI_REFERENCE = FONT_SIZE.base; // 16 + +export interface AppearanceInput { + uiFontFamily: string; // "" -> default stack + monoFontFamily: string; // "" -> default stack + uiFontSize: number; // already clamped + codeFontSize: number; // already clamped + syntaxTheme: SyntaxThemeId; +} + +/** + * Build the font-size ramp from the canonical `FONT_SIZE` ramp, scaled + * proportionally by `uiSize / 16` so the type hierarchy is preserved at non-default + * sizes. Deriving from the authored ramp — NOT the live (possibly already-scaled) + * theme — makes `applyAppearance` idempotent: repeated applies never compound, and a + * code-size change (uiSize unchanged) leaves the UI ramp at its authored values. + * `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate + * control on a separate semantic axis (mono/diff text). + */ +function scaleFontSize(uiSize: number, codeSize: number): Theme["fontSize"] { + const r = uiSize / BASE_UI_REFERENCE; + return { + xs: Math.round(FONT_SIZE.xs * r), + sm: Math.round(FONT_SIZE.sm * r), + base: Math.round(FONT_SIZE.base * r), + lg: Math.round(FONT_SIZE.lg * r), + xl: Math.round(FONT_SIZE.xl * r), + "2xl": Math.round(FONT_SIZE["2xl"] * r), + "3xl": Math.round(FONT_SIZE["3xl"] * r), + "4xl": Math.round(FONT_SIZE["4xl"] * r), + code: codeSize, // absolute, NOT scaled + }; +} + +/** + * Patch every registered Unistyles theme with the user's appearance choices. + * All six keys are patched because the active theme can change and adaptive mode + * can flip light/dark — patching all keys keeps the active key always current and + * makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. + */ +export function applyAppearance(input: AppearanceInput): void { + const ui = input.uiFontFamily.trim() || DEFAULT_UI_FONT_STACK; + const mono = input.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK; + const diffLineHeight = Math.round(input.codeFontSize * 1.5); // couple to code size + + for (const key of ALL_THEME_KEYS) { + // Spread `...t` first — `updateTheme` replaces the stored theme, it does not + // merge; an omitted key would be dropped. `syntax` follows the theme's own + // scheme for `auto`; named palettes ignore it. `colors.base`/plain text stays + // `theme.colors.foreground` (owned by `syntaxTokenStyles.base`, not patched). + // + // Narrow on the `colorScheme` discriminant before spreading: the updater must + // return the theme union, and a spread of the union widens `colorScheme` to + // `"light" | "dark"`, assignable to neither concrete member. Each branch spreads + // a single narrowed theme type. + UnistylesRuntime.updateTheme(key, (t) => { + const fontFamily = { ui, mono }; + const fontSize = scaleFontSize(input.uiFontSize, input.codeFontSize); + const lineHeight = { ...t.lineHeight, diff: diffLineHeight }; + if (t.colorScheme === "light") { + return { + ...t, + fontFamily, + fontSize, + lineHeight, + colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) }, + }; + } + return { + ...t, + fontFamily, + fontSize, + lineHeight, + colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) }, + }; + }); + } + + // Web: apply the UI font app-wide (RN-web stamps a default font on every text + // element, so it can't be done through the theme alone). No-op on native. + applyRootUiFont(ui); +} diff --git a/packages/app/src/screens/settings/appearance/apply-root-font.ts b/packages/app/src/screens/settings/appearance/apply-root-font.ts new file mode 100644 index 000000000..6b3b3836e --- /dev/null +++ b/packages/app/src/screens/settings/appearance/apply-root-font.ts @@ -0,0 +1,4 @@ +// Native (and default) no-op: React Native has no global font cascade, so the +// interface font applies only where components read theme.fontFamily.ui. The web +// build (apply-root-font.web.ts) overrides this to apply it app-wide. +export function applyRootUiFont(_uiFontStack: string): void {} diff --git a/packages/app/src/screens/settings/appearance/apply-root-font.web.ts b/packages/app/src/screens/settings/appearance/apply-root-font.web.ts new file mode 100644 index 000000000..3fdfe615c --- /dev/null +++ b/packages/app/src/screens/settings/appearance/apply-root-font.web.ts @@ -0,0 +1,33 @@ +// Apply the interface (UI) font app-wide on web. +// +// react-native-web stamps a hardcoded default font onto every text element, so a +// plain `body { font-family }` never cascades in — the element already has its own +// font. Instead we inject ONE rule that points all text at a CSS variable and set +// that variable live. The selector is high-specificity (1,2,0) so it deterministically +// beats both RN-web's base font and Unistyles' generated classes (0,1,0) — no reliance +// on stylesheet order. Code/diff/terminal surfaces carry `data-pmono` (and have their +// subtree excluded via `:not([data-pmono] *)`) so they keep their monospace font. +const STYLE_ID = "paseo-ui-font"; +const RULE = "#root *:not([data-pmono]):not([data-pmono] *){font-family:var(--paseo-ui-font);}"; + +export function applyRootUiFont(uiFontStack: string): void { + if (typeof document === "undefined") return; + // Strip anything that could break out of the CSS value; commas/quotes/spaces in a + // font stack are fine. + const value = uiFontStack + .replace(/[<>{}();]/g, "") + .replace(/[\r\n]/g, " ") + .trim(); + if (value.length === 0) return; + + document.documentElement.style.setProperty("--paseo-ui-font", value); + + // The rule itself is static (references the variable); inject it once. + let style = document.getElementById(STYLE_ID); + if (!style) { + style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = RULE; + document.head.appendChild(style); + } +} diff --git a/packages/app/src/screens/settings/appearance/preview-snippet.ts b/packages/app/src/screens/settings/appearance/preview-snippet.ts new file mode 100644 index 000000000..01a4de0a3 --- /dev/null +++ b/packages/app/src/screens/settings/appearance/preview-snippet.ts @@ -0,0 +1,21 @@ +// A small, syntax-rich TypeScript change rendered side-by-side in the appearance +// preview. BEFORE and AFTER are aligned 1:1 (5 lines each); only the indices in +// CHANGED_LINE_INDICES differ, so the diff tints land on matching rows. + +export const PREVIEW_BEFORE: string[] = [ + "// Format a price for display", + "export function formatPrice(cents: number) {", + " const amount = cents / 100;", + ' return "$" + amount;', + "}", +]; + +export const PREVIEW_AFTER: string[] = [ + "// Format a price for display", + "export function formatPrice(cents: number): string {", + " const amount = cents / 100;", + " return `$${amount.toFixed(2)}`;", + "}", +]; + +export const CHANGED_LINE_INDICES: ReadonlySet = new Set([1, 3]); diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index d0d597f46..682e589f3 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -16,11 +16,11 @@ import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { Button } from "@/components/ui/button"; -import { Fonts } from "@/constants/theme"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isNative, isWeb } from "@/constants/platform"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; interface StartupSplashScreenProps { bootstrapState?: { @@ -236,7 +236,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, fontSize: theme.fontSize.code, lineHeight: 20, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, }, logsMeta: { color: theme.colors.foregroundMuted, @@ -257,7 +257,7 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], }, logsText: { - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: 18, @@ -417,7 +417,9 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps GitHub and include the logs below. - {bootstrapState.splashError} + + {bootstrapState.splashError} + {daemonLogs?.logPath ? {daemonLogs.logPath} : null} @@ -427,7 +429,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps contentContainerStyle={styles.logsContent} showsVerticalScrollIndicator > - + {logsText} diff --git a/packages/app/src/styles/code-surface.ts b/packages/app/src/styles/code-surface.ts new file mode 100644 index 000000000..bdf20eed4 --- /dev/null +++ b/packages/app/src/styles/code-surface.ts @@ -0,0 +1,7 @@ +// Marker for code / diff / monospace surfaces. On web, the app-wide interface-font +// rule (see screens/settings/appearance/apply-root-font.web.ts) targets +// `#root *:not([data-pmono]):not([data-pmono] *)`, so tagging a code container with +// this dataSet excludes it and its subtree — they keep their monospace font. On +// native it renders nothing and is harmless. Use a shared stable reference so it +// doesn't trip the react-perf "new object as prop" rule. +export const CODE_SURFACE_DATASET = { pmono: "" } as const; diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index 7cea4afb9..8829af6bf 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -1,5 +1,4 @@ import type { Theme } from "./theme"; -import { Fonts } from "@/constants/theme"; import { isWeb } from "@/constants/platform"; const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {}; @@ -21,7 +20,9 @@ export function createMarkdownStyles(theme: Theme) { ...webSelectableTextStyle, color: theme.colors.foreground, fontSize: theme.fontSize.base, - lineHeight: 22, + // Prose line-height scales with the UI ramp (≈22 at base 16), NOT the + // code-size-coupled lineHeight.diff token used by code/diff surfaces. + lineHeight: Math.round(theme.fontSize.base * 1.4), flexShrink: 1, minWidth: 0, width: "100%" as const, @@ -169,8 +170,8 @@ export function createMarkdownStyles(theme: Theme) { paddingVertical: 2, borderRadius: theme.borderRadius.md, borderWidth: 0, - fontFamily: Fonts.mono, - fontSize: theme.fontSize.base - 3, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, }, code_block: { @@ -179,7 +180,7 @@ export function createMarkdownStyles(theme: Theme) { color: theme.colors.foreground, padding: theme.spacing[3], borderRadius: theme.borderRadius.md, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, marginVertical: theme.spacing[2], }, @@ -192,7 +193,7 @@ export function createMarkdownStyles(theme: Theme) { borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, - fontFamily: Fonts.mono, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, marginVertical: theme.spacing[3], }, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 680cc68d3..6ad48e8e4 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -1,3 +1,4 @@ +import { Platform } from "react-native"; import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight"; export const baseColors = { @@ -485,16 +486,48 @@ export const OPACITY = { 100: 1, } as const; -const commonTheme = { +// Platform default font stacks — copied verbatim from constants/theme.ts `Fonts` +// (sans -> ui, mono -> mono). These seed the dynamic `fontFamily` theme token and +// are the fallback an empty user-supplied family resolves to at apply time. +export const DEFAULT_UI_FONT_STACK: string = Platform.select({ + ios: "system-ui", + default: "normal", + web: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", +}); + +export const DEFAULT_MONO_FONT_STACK: string = Platform.select({ + ios: "ui-monospace", + default: "monospace", + web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", +}); + +// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain +// `number`/`string` (not narrowed by `as const`) so the appearance updater can patch +// them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their +// literal types. +interface CommonTheme { + spacing: typeof SPACING; + fontSize: Record; + fontFamily: { ui: string; mono: string }; + lineHeight: Record; + iconSize: typeof ICON_SIZE; + fontWeight: typeof FONT_WEIGHT; + borderRadius: typeof BORDER_RADIUS; + borderWidth: typeof BORDER_WIDTH; + opacity: typeof OPACITY; +} + +const commonTheme: CommonTheme = { spacing: SPACING, fontSize: FONT_SIZE, + fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK }, lineHeight: LINE_HEIGHT, iconSize: ICON_SIZE, fontWeight: FONT_WEIGHT, borderRadius: BORDER_RADIUS, borderWidth: BORDER_WIDTH, opacity: OPACITY, -} as const; +}; const darkShadow = { sm: { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index cab26c8bc..4cf9c6708 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -86,7 +86,7 @@ interface StubTerminal { resize?: (cols: number, rows: number) => void; focus: () => void; refresh?: (start: number, end: number) => void; - options?: { theme?: unknown; scrollback?: number }; + options?: { theme?: unknown; scrollback?: number; fontFamily?: string; fontSize?: number }; rows?: number; cols?: number; } @@ -384,6 +384,31 @@ describe("terminal-emulator-runtime", () => { expect(refresh).toHaveBeenCalledWith(0, 11); }); + it("updates terminal font without remounting", () => { + const runtime = new TerminalEmulatorRuntime(); + const refresh = vi.fn(); + const fitAndEmitResize = vi.fn(); + const terminal: StubTerminal = { + write: () => {}, + reset: () => {}, + focus: () => {}, + refresh, + options: { fontFamily: "before", fontSize: 13 }, + rows: 12, + cols: 40, + }; + (runtime as unknown as { terminal: StubTerminal }).terminal = terminal; + (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = + fitAndEmitResize; + + runtime.setFont({ fontFamily: " Menlo ", fontSize: 18 }); + + expect(terminal.options?.fontFamily).toBe("Menlo"); + expect(terminal.options?.fontSize).toBe(18); + expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true }); + expect(refresh).toHaveBeenCalledWith(0, 11); + }); + it("passively refits when the page becomes visible again", () => { const runtime = new TerminalEmulatorRuntime(); const fitAndEmitResize = vi.fn(); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 8773f586f..7da237f6b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -37,6 +37,8 @@ export interface TerminalEmulatorRuntimeMountInput { initialSnapshot: TerminalState | null; scrollback: number; theme: ITheme; + fontFamily?: string; + fontSize?: number; } export interface TerminalEmulatorRuntimeCallbacks { @@ -107,6 +109,7 @@ const isAppleHandheld = }); const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; +const DEFAULT_TERMINAL_FONT_SIZE = 13; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0); @@ -147,6 +150,17 @@ const DEFAULT_TERMINAL_FONT_FAMILY = [ "monospace", ].join(", "); +function resolveTerminalFontFamily(fontFamily: string | undefined): string { + const trimmed = fontFamily?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY; +} + +function resolveTerminalFontSize(fontSize: number | undefined): number { + return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0 + ? fontSize + : DEFAULT_TERMINAL_FONT_SIZE; +} + function withOverviewRulerBorderHidden(theme: ITheme): ITheme { return { ...theme, @@ -210,8 +224,8 @@ export class TerminalEmulatorRuntime { convertEol: false, cursorBlink: true, cursorStyle: "bar", - fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, - fontSize: 13, + fontFamily: resolveTerminalFontFamily(input.fontFamily), + fontSize: resolveTerminalFontSize(input.fontSize), lineHeight: 1.0, macOptionIsMeta: true, minimumContrastRatio: 1, @@ -668,6 +682,24 @@ export class TerminalEmulatorRuntime { this.refreshVisibleRows(); } + setFont(input: { fontFamily?: string; fontSize?: number }): void { + const terminal = this.terminal; + if (!terminal) { + return; + } + + try { + terminal.options.fontFamily = resolveTerminalFontFamily(input.fontFamily); + terminal.options.fontSize = resolveTerminalFontSize(input.fontSize); + } catch { + // ignore + return; + } + + this.fitAndEmitResize?.({ force: true }); + this.refreshVisibleRows(); + } + focus(input?: { forceRefocus?: boolean }): void { const terminal = this.terminal; if (!terminal) { diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index f69a550e2..5d410f87f 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -18,6 +18,8 @@ interface MountMessage { initialSnapshot: TerminalState | null; scrollbackLines: number; theme: ITheme; + fontFamily?: string; + fontSize?: number; pendingModifiers: PendingTerminalModifiers; swipeGesturesEnabled: boolean; } @@ -33,6 +35,7 @@ type InboundMessage = | { type: "resize"; streamKey: string; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setScrollback"; streamKey: string; lines: number } + | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } | { @@ -227,6 +230,8 @@ class TerminalWebViewBridge { MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } >, ): void { + if (this.receiveConfigurationMessage(message)) return; + switch (message.type) { case "writeOutput": this.runtime?.write({ data: encodeTerminalOutput(message.text) }); @@ -246,19 +251,34 @@ class TerminalWebViewBridge { case "resize": this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false }); break; + } + } + + private receiveConfigurationMessage( + message: Exclude< + InboundMessage, + MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } + >, + ): boolean { + switch (message.type) { case "setTheme": this.applyThemeBackground(message.theme); this.runtime?.setTheme({ theme: message.theme }); - break; + return true; case "setScrollback": this.runtime?.setScrollback({ lines: message.lines }); - break; + return true; + case "setFont": + this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize }); + return true; case "setPendingModifiers": this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); - break; + return true; case "setSwipeGesturesEnabled": this.swipeGesturesEnabled = message.enabled; - break; + return true; + default: + return false; } } @@ -300,6 +320,8 @@ class TerminalWebViewBridge { initialSnapshot: message.initialSnapshot, scrollback: message.scrollbackLines, theme: message.theme, + fontFamily: message.fontFamily, + fontSize: message.fontSize, }); sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true }); } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index 124b06083..d135b3ba6 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/types/react-native-dataset.d.ts b/packages/app/src/types/react-native-dataset.d.ts new file mode 100644 index 000000000..a681cc65d --- /dev/null +++ b/packages/app/src/types/react-native-dataset.d.ts @@ -0,0 +1,14 @@ +// react-native-web renders the `dataSet` prop to `data-*` attributes on the DOM +// node, but the bundled react-native types don't declare it. Augment View/Text +// props so code surfaces can carry a marker for the web interface-font rule +// (see styles/code-surface.ts and screens/settings/appearance/apply-root-font.web.ts). +import "react-native"; + +declare module "react-native" { + interface ViewProps { + dataSet?: Record; + } + interface TextProps { + dataSet?: Record; + } +} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index ee3e0a6ef..7d210c194 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -377,6 +377,7 @@ export function buildHostNewWorkspaceRoute( export const SETTINGS_SECTION_SLUGS = [ "general", + "appearance", "shortcuts", "integrations", "permissions", diff --git a/packages/highlight/src/__tests__/themes.test.ts b/packages/highlight/src/__tests__/themes.test.ts new file mode 100644 index 000000000..0b0d67e55 --- /dev/null +++ b/packages/highlight/src/__tests__/themes.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { darkHighlightColors, lightHighlightColors } from "../colors.js"; +import { SYNTAX_THEME_IDS, isSyntaxThemeId, resolveSyntaxColors } from "../themes.js"; +import type { HighlightStyle } from "../types.js"; + +const allStyles: HighlightStyle[] = [ + "keyword", + "comment", + "string", + "number", + "literal", + "function", + "definition", + "class", + "type", + "tag", + "attribute", + "property", + "variable", + "operator", + "punctuation", + "regexp", + "escape", + "meta", + "heading", + "link", +]; + +const colorSchemes: ("light" | "dark")[] = ["light", "dark"]; + +describe("resolveSyntaxColors", () => { + for (const id of SYNTAX_THEME_IDS) { + for (const colorScheme of colorSchemes) { + describe(`${id} (${colorScheme})`, () => { + const colors = resolveSyntaxColors(id, colorScheme); + + it("covers all HighlightStyle values", () => { + for (const style of allStyles) { + expect(colors[style]).toBeDefined(); + expect(typeof colors[style]).toBe("string"); + } + }); + + it("has valid hex color values", () => { + for (const style of allStyles) { + expect(colors[style]).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); + }); + } + } + + it("github + light deep-equals lightHighlightColors", () => { + expect(resolveSyntaxColors("github", "light")).toEqual(lightHighlightColors); + }); + + it("github + dark deep-equals darkHighlightColors", () => { + expect(resolveSyntaxColors("github", "dark")).toEqual(darkHighlightColors); + }); + + it("dark-only themes ignore the color scheme", () => { + for (const id of ["dracula", "nord"] as const) { + expect(resolveSyntaxColors(id, "light")).toEqual(resolveSyntaxColors(id, "dark")); + } + }); +}); + +describe("isSyntaxThemeId", () => { + it("accepts every id in SYNTAX_THEME_IDS", () => { + for (const id of SYNTAX_THEME_IDS) { + expect(isSyntaxThemeId(id)).toBe(true); + } + }); + + it("rejects unknown ids", () => { + expect(isSyntaxThemeId("auto")).toBe(false); + expect(isSyntaxThemeId("github-light")).toBe(false); + expect(isSyntaxThemeId("one-dark")).toBe(false); + expect(isSyntaxThemeId("nope")).toBe(false); + }); +}); diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index cc80ee238..ea5c4eb3c 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -2,3 +2,10 @@ export type { HighlightStyle, HighlightToken } from "./types.js"; export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js"; export { highlightCode, highlightLine } from "./highlighter.js"; export { darkHighlightColors, lightHighlightColors } from "./colors.js"; +export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js"; +export { + SYNTAX_THEME_IDS, + SYNTAX_THEME_OPTIONS, + isSyntaxThemeId, + resolveSyntaxColors, +} from "./themes.js"; diff --git a/packages/highlight/src/themes.ts b/packages/highlight/src/themes.ts new file mode 100644 index 000000000..51e12f2d2 --- /dev/null +++ b/packages/highlight/src/themes.ts @@ -0,0 +1,278 @@ +import { darkHighlightColors, lightHighlightColors } from "./colors.js"; +import type { HighlightStyle } from "./types.js"; + +// Syntax-highlighting themes are chosen independently of the app's light/dark +// theme. The ONLY coupling is the light/dark axis: a theme that ships both +// variants uses its light palette on a light app and its dark palette on a dark +// app (resolveSyntaxColors receives the active theme's colorScheme). Dark-only +// themes (Dracula, Nord) apply their single palette regardless. The code frame +// — gutter, line numbers, background — follows the app theme, not the palette. +export type SyntaxThemeId = + | "github" + | "catppuccin" + | "dracula" + | "tokyo-night" + | "one" + | "nord" + | "gruvbox" + | "solarized"; + +export const SYNTAX_THEME_IDS: readonly SyntaxThemeId[] = [ + "github", + "catppuccin", + "dracula", + "tokyo-night", + "one", + "nord", + "gruvbox", + "solarized", +]; + +export interface SyntaxThemeOption { + id: SyntaxThemeId; + label: string; +} + +export const SYNTAX_THEME_OPTIONS: readonly SyntaxThemeOption[] = [ + { id: "github", label: "GitHub" }, + { id: "catppuccin", label: "Catppuccin" }, + { id: "dracula", label: "Dracula" }, + { id: "tokyo-night", label: "Tokyo Night" }, + { id: "one", label: "One" }, + { id: "nord", label: "Nord" }, + { id: "gruvbox", label: "Gruvbox" }, + { id: "solarized", label: "Solarized" }, +]; + +export type SyntaxColors = Record; + +// A compact per-theme role palette. `expandRolePalette` maps these roles onto +// all 20 HighlightStyle tokens, so every theme stays complete and internally +// consistent. GitHub keeps its own hand-tuned maps (colors.ts) for exactness +// and byte-for-byte back-compat with the previous default. +interface RolePalette { + base: string; // plain text: variables, punctuation + keyword: string; + comment: string; // comments, meta + string: string; // strings, regexp, links + number: string; // numbers, literals, escapes + function: string; // functions, definitions, headings + type: string; // types, classes + tag: string; + attribute: string; // attributes, properties + operator: string; +} + +function expandRolePalette(r: RolePalette): SyntaxColors { + return { + keyword: r.keyword, + comment: r.comment, + string: r.string, + number: r.number, + literal: r.number, + function: r.function, + definition: r.function, + class: r.type, + type: r.type, + tag: r.tag, + attribute: r.attribute, + property: r.attribute, + variable: r.base, + operator: r.operator, + punctuation: r.base, + regexp: r.string, + escape: r.number, + meta: r.comment, + heading: r.function, + link: r.string, + }; +} + +// --- Catppuccin (Latte light / Mocha dark) ------------------------------- +const catppuccinLatte: RolePalette = { + base: "#4c4f69", + keyword: "#8839ef", + comment: "#8c8fa1", + string: "#40a02b", + number: "#fe640b", + function: "#1e66f5", + type: "#df8e1d", + tag: "#8839ef", + attribute: "#df8e1d", + operator: "#04a5e5", +}; +const catppuccinMocha: RolePalette = { + base: "#cdd6f4", + keyword: "#cba6f7", + comment: "#9399b2", + string: "#a6e3a1", + number: "#fab387", + function: "#89b4fa", + type: "#f9e2af", + tag: "#cba6f7", + attribute: "#f9e2af", + operator: "#89dceb", +}; + +// --- Dracula (dark only) ------------------------------------------------- +const dracula: RolePalette = { + base: "#f8f8f2", + keyword: "#ff79c6", + comment: "#6272a4", + string: "#f1fa8c", + number: "#bd93f9", + function: "#50fa7b", + type: "#8be9fd", + tag: "#ff79c6", + attribute: "#50fa7b", + operator: "#ff79c6", +}; + +// --- Tokyo Night (Day light / Night dark) -------------------------------- +const tokyoDay: RolePalette = { + base: "#3760bf", + keyword: "#9854f1", + comment: "#848cb5", + string: "#587539", + number: "#b15c00", + function: "#2e7de9", + type: "#007197", + tag: "#f52a65", + attribute: "#8c6c3e", + operator: "#006a83", +}; +const tokyoNight: RolePalette = { + base: "#c0caf5", + keyword: "#bb9af7", + comment: "#565f89", + string: "#9ece6a", + number: "#ff9e64", + function: "#7aa2f7", + type: "#2ac3de", + tag: "#f7768e", + attribute: "#e0af68", + operator: "#89ddff", +}; + +// --- One (One Light / One Dark) ------------------------------------------ +const oneLight: RolePalette = { + base: "#383a42", + keyword: "#a626a4", + comment: "#a0a1a7", + string: "#50a14f", + number: "#986801", + function: "#4078f2", + type: "#c18401", + tag: "#e45649", + attribute: "#986801", + operator: "#0184bc", +}; +const oneDark: RolePalette = { + base: "#abb2bf", + keyword: "#c678dd", + comment: "#5c6370", + string: "#98c379", + number: "#d19a66", + function: "#61afef", + type: "#e5c07b", + tag: "#e06c75", + attribute: "#d19a66", + operator: "#56b6c2", +}; + +// --- Nord (dark only) ---------------------------------------------------- +const nord: RolePalette = { + base: "#d8dee9", + keyword: "#81a1c1", + comment: "#616e88", + string: "#a3be8c", + number: "#b48ead", + function: "#88c0d0", + type: "#8fbcbb", + tag: "#81a1c1", + attribute: "#8fbcbb", + operator: "#81a1c1", +}; + +// --- Gruvbox (Light / Dark) ---------------------------------------------- +const gruvboxLight: RolePalette = { + base: "#3c3836", + keyword: "#9d0006", + comment: "#928374", + string: "#79740e", + number: "#8f3f71", + function: "#427b58", + type: "#b57614", + tag: "#076678", + attribute: "#b57614", + operator: "#af3a03", +}; +const gruvboxDark: RolePalette = { + base: "#ebdbb2", + keyword: "#fb4934", + comment: "#928374", + string: "#b8bb26", + number: "#d3869b", + function: "#8ec07c", + type: "#fabd2f", + tag: "#83a598", + attribute: "#fabd2f", + operator: "#fe8019", +}; + +// --- Solarized (Light / Dark — shared accents, different base/comment) ---- +const solarizedLight: RolePalette = { + base: "#657b83", + keyword: "#859900", + comment: "#93a1a1", + string: "#2aa198", + number: "#d33682", + function: "#268bd2", + type: "#b58900", + tag: "#268bd2", + attribute: "#b58900", + operator: "#859900", +}; +const solarizedDark: RolePalette = { + base: "#839496", + keyword: "#859900", + comment: "#586e75", + string: "#2aa198", + number: "#d33682", + function: "#268bd2", + type: "#b58900", + tag: "#268bd2", + attribute: "#b58900", + operator: "#859900", +}; + +export function isSyntaxThemeId(value: string): value is SyntaxThemeId { + return (SYNTAX_THEME_IDS as readonly string[]).includes(value); +} + +// Resolve a theme id + the app's color scheme to a full token palette. Only the +// light/dark axis is coupled to the app; the theme brand is the user's choice. +export function resolveSyntaxColors( + id: SyntaxThemeId, + colorScheme: "light" | "dark", +): SyntaxColors { + const dark = colorScheme === "dark"; + switch (id) { + case "github": + return dark ? darkHighlightColors : lightHighlightColors; + case "catppuccin": + return expandRolePalette(dark ? catppuccinMocha : catppuccinLatte); + case "dracula": + return expandRolePalette(dracula); + case "tokyo-night": + return expandRolePalette(dark ? tokyoNight : tokyoDay); + case "one": + return expandRolePalette(dark ? oneDark : oneLight); + case "nord": + return expandRolePalette(nord); + case "gruvbox": + return expandRolePalette(dark ? gruvboxDark : gruvboxLight); + case "solarized": + return expandRolePalette(dark ? solarizedDark : solarizedLight); + } +}