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:
@@ -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]);
|
||||
Reference in New Issue
Block a user