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
+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>