Add appearance settings for theme, fonts, and syntax highlighting (#1236)

* 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
This commit is contained in:
Mohamed Boudra
2026-05-31 16:09:01 +08:00
committed by GitHub
parent f26a81798b
commit a1c8e1a1f9
43 changed files with 2083 additions and 247 deletions
+6 -1
View File
@@ -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<void> {
}
export async function expectGeneralContent(page: Page): Promise<void> {
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<void> {
await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible();
}
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
@@ -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 }) => {
+22
View File
@@ -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 (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
+3 -3
View File
@@ -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 = (
<View style={linesContainerStyle}>
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={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
@@ -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,
},
+4 -4
View File
@@ -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 = (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
@@ -8,6 +8,7 @@ import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
interface HighlightedCodeBlockProps {
@@ -75,6 +76,7 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
return (
<View
style={containerStyle}
dataSet={CODE_SURFACE_DATASET}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -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 (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map((line) => (
<ContentLine key={line.key} line={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 (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map((line, index) => (
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
))}
@@ -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,
+6 -6
View File
@@ -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({
</View>
</View>
{isExpanded && metadata && (
<View style={activityLogStylesheet.metadataContainer}>
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
<Text style={activityLogStylesheet.metadataText}>
{JSON.stringify(metadata, null, 2)}
</Text>
@@ -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,
},
@@ -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 }) {
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
<Text
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
{model.description ? (
@@ -89,7 +94,12 @@ function CustomModelRow({
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
<Text
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
<View style={sheetStyles.modelRowFiller} />
@@ -307,7 +317,7 @@ function DiagnosticSubSheet({
body = (
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={sheetStyles.codeText} selectable>
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
{diagnostic}
</Text>
</ScrollView>
@@ -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,
@@ -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 });
@@ -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<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const mountedThemeRef = useRef<ITheme>(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<HTMLElement | null>(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 () => {};
@@ -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}
@@ -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}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
<Text style={styles.shellPrompt}>$ </Text>
{normalizedCommand}
@@ -224,7 +224,7 @@ function WorktreeSetupDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
@@ -383,7 +383,7 @@ function SubAgentDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
@@ -466,7 +466,7 @@ function ScrollableTextSection({
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{content}
</Text>
)}
@@ -498,7 +498,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{result ? `${url}\n\n${result}` : url}
</Text>
</ScrollView>
@@ -542,7 +542,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.content}
</Text>
</ScrollView>
@@ -553,7 +553,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.filePaths && detail.filePaths.length > 0) {
out.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.filePaths.join("\n")}
</Text>
</View>,
@@ -562,7 +562,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.webResults && detail.webResults.length > 0) {
out.push(
<View key="search-web-results" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>,
@@ -571,7 +571,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.annotations && detail.annotations.length > 0) {
out.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.annotations.join("\n\n")}
</Text>
</View>,
@@ -638,7 +638,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{value}
</Text>
</ScrollView>
@@ -738,7 +738,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE}>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
{errorText}
</Text>
</ScrollView>
@@ -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,
-27
View File
@@ -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",
},
});
@@ -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)
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={styles.logOutput} selectable>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
@@ -163,7 +164,7 @@ function DaemonCliStatusModal({
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
+6 -6
View File
@@ -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 (
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE}>
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<SplitDiffColumn
rows={rows}
side="left"
@@ -979,7 +979,7 @@ function DiffFileBody({
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -1006,7 +1006,7 @@ function DiffFileBody({
const textViewportWidth =
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
return (
<View style={DIFF_CONTENT_ROW_STYLE}>
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -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",
},
@@ -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<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -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();
});
});
@@ -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<ServiceUrlBehavior>(["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<AppSettings>): Partial<AppSettings> {
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;
+4 -4
View File
@@ -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"
>
<Text selectable style={styles.logText}>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
@@ -404,7 +404,7 @@ function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: s
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
{log}
</Text>
</ScrollView>
@@ -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,
+7 -127
View File
@@ -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 <Sun size={size} color={color} />;
case "dark":
return <Moon size={size} color={color} />;
case "auto":
return <Monitor size={size} color={color} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
}
}
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 <View style={swatchStyle} />;
}
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
}
@@ -210,16 +173,6 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
];
}
const THEME_LABELS: Record<AppSettings["theme"], string> = {
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(
() => <ThemeIcon theme={themeValue} size={iconSize} color={iconColor} />,
[themeValue, iconSize, iconColor],
);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{THEME_LABELS[themeValue]}
</DropdownMenuItem>
);
}
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({
<SettingsSection title="General">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger style={themeTriggerStyle}>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>{THEME_LABELS[settings.theme]}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{(["light", "dark", "auto"] as const).map((t) => (
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
<DropdownMenuSeparator />
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Default send</Text>
<Text style={settingsStyles.rowHint}>
@@ -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) {
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleThemeChange={handleThemeChange}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "appearance":
return <AppearanceSection />;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
@@ -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 <Text>, 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 (
<View
accessibilityRole="image"
accessibilityLabel="Live preview of the syntax theme and code font"
dataSet={CODE_SURFACE_DATASET}
style={styles.card}
>
{rows.map((row) => (
<View key={row.key} style={rowStyle(row.type)}>
<Text style={codeStyle}>
<Text style={markerStyle(row.type)}>{row.marker}</Text>
{row.tokens
? row.tokens.map((token) => (
<Text
key={token.key}
style={token.style ? syntaxTokenStyleFor(token.style) : undefined}
>
{token.text}
</Text>
))
: row.fallbackText}
</Text>
</View>
))}
</View>
);
}
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,
},
}));
@@ -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<AppSettings["theme"], string> = {
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<string> = 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 <ThemedSun size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "dark":
return <ThemedMoon size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "auto":
return <ThemedMonitor size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[themeValue]} />;
}
}
interface ThemeSwatchProps {
color: string;
}
function ThemeSwatch({ color }: ThemeSwatchProps) {
const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]);
return <View style={swatchStyle} />;
}
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(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{THEME_LABELS[themeValue]}
</DropdownMenuItem>
);
}
interface ThemeRowProps {
value: AppSettings["theme"];
onChange: (theme: AppSettings["theme"]) => void;
}
function ThemeRow({ value, onChange }: ThemeRowProps) {
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={`Theme: ${THEME_LABELS[value]}`}
>
<ThemeLeading themeValue={value} />
<Text style={styles.triggerText}>{THEME_LABELS[value]}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{PRIMARY_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
<DropdownMenuSeparator />
{DARK_VARIANT_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// 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 (
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
value={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
onSubmitEditing={handleCommit}
placeholder={placeholder}
placeholderTextColor={styles.placeholderColor.color}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.fontFamilyInput}
accessibilityLabel={accessibilityLabel}
/>
</View>
);
}
interface FontSizeRowProps {
title: string;
accessibilityLabel: string;
draft: string;
onChangeDraft: (value: string) => void;
onCommit: () => void;
}
function FontSizeRow({
title,
accessibilityLabel,
draft,
onChangeDraft,
onCommit,
}: FontSizeRowProps) {
return (
<View style={styles.rowWithBorder}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
</View>
<View style={styles.sizeField}>
<TextInput
value={draft}
onChangeText={onChangeDraft}
onBlur={onCommit}
onSubmitEditing={onCommit}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.sizeInput}
accessibilityLabel={accessibilityLabel}
/>
<Text style={styles.unit}>px</Text>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// 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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{option.label}
</DropdownMenuItem>
);
}
interface SyntaxRowProps {
value: SyntaxThemeId;
onChange: (id: SyntaxThemeId) => void;
}
function SyntaxRow({ value, onChange }: SyntaxRowProps) {
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Highlight theme</Text>
<Text style={settingsStyles.rowHint}>Colors for code, independent of the app theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={`Highlight theme: ${syntaxLabelForId(value)}`}
>
<Text style={styles.triggerText}>{syntaxLabelForId(value)}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SYNTAX_THEME_OPTIONS.map((option) => (
<SyntaxMenuItem
key={option.id}
option={option}
selected={value === option.id}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// 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 (
<View>
<SettingsSection title="Theme">
<View style={settingsStyles.card}>
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
</View>
</SettingsSection>
<SettingsSection title="Fonts">
<View style={settingsStyles.card}>
<FontFamilyRow
title="Interface font"
hint="Used across the app. Leave empty for the system default"
accessibilityLabel="Interface font family"
placeholder={UI_FONT_PLACEHOLDER}
value={settings.uiFontFamily}
draft={uiFontDraft}
withBorder={false}
onChangeDraft={setUiFontDraft}
onCommit={commitUiFontFamily}
/>
<FontSizeRow
title="Interface size"
accessibilityLabel="Interface font size"
draft={uiSizeDraft}
onChangeDraft={handleUiSizeChange}
onCommit={commitUiSize}
/>
<FontFamilyRow
title="Code font"
hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
accessibilityLabel="Code font family"
placeholder={MONO_FONT_PLACEHOLDER}
value={settings.monoFontFamily}
draft={monoFontDraft}
withBorder
onChangeDraft={setMonoFontDraft}
onCommit={commitMonoFontFamily}
/>
<FontSizeRow
title="Code size"
accessibilityLabel="Code font size"
draft={codeSizeDraft}
onChangeDraft={handleCodeSizeChange}
onCommit={commitCodeSize}
/>
</View>
</SettingsSection>
<SettingsSection title="Syntax">
<View style={settingsStyles.card}>
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
</View>
<View style={styles.preview}>
<AppearancePreview overrides={previewOverrides} />
</View>
</SettingsSection>
</View>
);
}
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,
},
}));
@@ -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<string, string> };
}
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> = {}): 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"));
});
});
@@ -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);
}
@@ -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 {}
@@ -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);
}
}
@@ -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<number> = new Set([1, 3]);
@@ -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.
</Text>
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
{bootstrapState.splashError}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
@@ -427,7 +429,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
<Text dataSet={CODE_SURFACE_DATASET} selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
+7
View File
@@ -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;
+7 -6
View File
@@ -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],
},
+35 -2
View File
@@ -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<keyof typeof FONT_SIZE, number>;
fontFamily: { ui: string; mono: string };
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
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: {
@@ -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();
@@ -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) {
@@ -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 });
}
File diff suppressed because one or more lines are too long
+14
View File
@@ -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<string, string | number | boolean | undefined>;
}
interface TextProps {
dataSet?: Record<string, string | number | boolean | undefined>;
}
}
+1
View File
@@ -377,6 +377,7 @@ export function buildHostNewWorkspaceRoute(
export const SETTINGS_SECTION_SLUGS = [
"general",
"appearance",
"shortcuts",
"integrations",
"permissions",
@@ -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);
});
});
+7
View File
@@ -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";
+278
View File
@@ -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<HighlightStyle, string>;
// 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);
}
}