Refine appearance settings and syntax highlighting

Keep parsed chat and tool detail content in sync with appearance token changes. Hide font-family controls on native, use One as the default syntax theme, add Swift highlighting, and give Nord a distinct light palette.
This commit is contained in:
Mohamed Boudra
2026-05-31 21:40:38 +07:00
parent 4cc72a84e8
commit 9f88b4e69c
21 changed files with 310 additions and 53 deletions

View File

@@ -301,6 +301,7 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`). - **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes. - **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry. - **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`. - **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Debugging ## Debugging

70
package-lock.json generated
View File

@@ -2813,6 +2813,50 @@
"integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==", "integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==",
"license": "MIT OR Apache-2.0" "license": "MIT OR Apache-2.0"
}, },
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -7874,6 +7918,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -16979,6 +17029,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -34033,6 +34089,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.21", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -36027,6 +36089,12 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -37674,6 +37742,8 @@
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.87", "version": "0.1.87",
"dependencies": { "dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0", "@lezer/css": "^1.3.0",

View File

@@ -12,6 +12,7 @@ import { isNative, isWeb } from "@/constants/platform";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useAssistantFileLinkResolverContext } from "./provider"; import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver"; import type { AssistantFileLinkSource } from "./resolver";
import { useFileLink } from "./use-file-link"; import { useFileLink } from "./use-file-link";
@@ -19,10 +20,16 @@ import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps { interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource; source: AssistantFileLinkSource;
style: StyleProp<TextStyle>; style: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode; children: ReactNode;
} }
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) { export function AssistantMarkdownLink({
source,
style,
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext(); const { configRef } = useAssistantFileLinkResolverContext();
@@ -52,7 +59,12 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
if (isNative) { if (isNative) {
return ( return (
<FileLinkHoverTooltip filePath={tooltipPath}> <FileLinkHoverTooltip filePath={tooltipPath}>
<Text accessibilityRole="link" onPress={onPress} style={style}> <Text
accessibilityRole="link"
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
onPress={onPress}
style={style}
>
{children} {children}
</Text> </Text>
</FileLinkHoverTooltip> </FileLinkHoverTooltip>
@@ -72,7 +84,9 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
onHoverIn={handleHoverIn} onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut} onHoverOut={handleHoverOut}
> >
<Text style={hoveredTextStyle}>{children}</Text> <Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
{children}
</Text>
</Pressable> </Pressable>
</a> </a>
); );
@@ -100,7 +114,7 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle], [inheritedStyles, codeInlineStyle, linkStyle],
); );
return ( return (
<AssistantMarkdownLink source={source} style={style}> <AssistantMarkdownLink source={source} style={style} monoSurface>
{children} {children}
</AssistantMarkdownLink> </AssistantMarkdownLink>
); );

View File

@@ -0,0 +1,66 @@
import { Fragment, type ReactNode } from "react";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
interface AppearanceStyleBoundaryProps {
appearanceKey?: string;
children: ReactNode;
}
function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) {
return <Fragment key={appearanceKey}>{children}</Fragment>;
}
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
appearanceKey: [
theme.fontFamily.ui,
theme.fontFamily.mono,
theme.fontSize.xs,
theme.fontSize.sm,
theme.fontSize.base,
theme.fontSize.lg,
theme.fontSize.xl,
theme.fontSize["2xl"],
theme.fontSize["3xl"],
theme.fontSize["4xl"],
theme.fontSize.code,
theme.lineHeight.diff,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.mutedForeground,
theme.colors.surface1,
theme.colors.surface2,
theme.colors.border,
theme.colors.accentBright,
theme.colors.syntax.keyword,
theme.colors.syntax.comment,
theme.colors.syntax.string,
theme.colors.syntax.number,
theme.colors.syntax.literal,
theme.colors.syntax.function,
theme.colors.syntax.definition,
theme.colors.syntax.class,
theme.colors.syntax.type,
theme.colors.syntax.tag,
theme.colors.syntax.attribute,
theme.colors.syntax.property,
theme.colors.syntax.variable,
theme.colors.syntax.operator,
theme.colors.syntax.punctuation,
theme.colors.syntax.regexp,
theme.colors.syntax.escape,
theme.colors.syntax.meta,
theme.colors.syntax.heading,
theme.colors.syntax.link,
].join("\u0000"),
});
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
export function AppearanceStyleBoundary({ children }: { children: ReactNode }) {
return (
<ThemedAppearanceStyleBoundary uniProps={appearanceStyleBoundaryMapping}>
{children}
</ThemedAppearanceStyleBoundary>
);
}

View File

@@ -3,6 +3,7 @@ import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "reac
interface MarkdownTextSpanProps { interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode; children: ReactNode;
} }

View File

@@ -4,6 +4,7 @@ import { UITextView } from "react-native-uitextview";
interface MarkdownTextSpanProps { interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode; children: ReactNode;
} }

View File

@@ -1,8 +1,10 @@
import { useMemo, type ReactNode } from "react"; import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
interface MarkdownTextSpanProps { interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -11,8 +13,12 @@ interface MarkdownTextSpanProps {
// react-native-uitextview: its transitive import of codegenNativeComponent // react-native-uitextview: its transitive import of codegenNativeComponent
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web // pulls in setUpReactDevTools, which doesn't resolve under Metro's web
// target in dev mode. // target in dev mode.
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) { export function MarkdownTextSpan({ style, monoSurface, children }: MarkdownTextSpanProps) {
return <Text style={style}>{children}</Text>; return (
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={style}>
{children}
</Text>
);
} }
interface MarkdownParagraphViewProps { interface MarkdownParagraphViewProps {

View File

@@ -11,6 +11,7 @@ import {
type TextStyle, type TextStyle,
} from "react-native"; } from "react-native";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import * as React from "react"; import * as React from "react";
import { import {
useState, useState,
@@ -1496,16 +1497,18 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress, onLinkPress,
}: MemoizedMarkdownBlockProps) { }: MemoizedMarkdownBlockProps) {
return ( return (
<ThemedMarkdown <AppearanceStyleBoundary>
uniProps={markdownStyleMapping} <ThemedMarkdown
rules={rules} uniProps={markdownStyleMapping}
markdownit={parser} rules={rules}
onLinkPress={onLinkPress} markdownit={parser}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS} onLinkPress={onLinkPress}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM} allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
> topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
{text} >
</ThemedMarkdown> {text}
</ThemedMarkdown>
</AppearanceStyleBoundary>
); );
}); });
@@ -1513,6 +1516,7 @@ interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle; inheritedStyles: TextStyle;
textStyle: TextStyle; textStyle: TextStyle;
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -1520,13 +1524,18 @@ function MarkdownInheritedText({
inheritedStyles, inheritedStyles,
textStyle, textStyle,
style: overrideStyle, style: overrideStyle,
monoSurface,
children, children,
}: MarkdownInheritedTextProps) { }: MarkdownInheritedTextProps) {
const style = useMemo( const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle], () => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle],
); );
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>; return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
{children}
</MarkdownTextSpan>
);
} }
interface MarkdownListItemContentProps { interface MarkdownListItemContentProps {
@@ -1696,6 +1705,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key} key={node.key}
inheritedStyles={inheritedStyles} inheritedStyles={inheritedStyles}
textStyle={styles.code_inline} textStyle={styles.code_inline}
monoSurface
> >
{content} {content}
</MarkdownInheritedText> </MarkdownInheritedText>

View File

@@ -8,6 +8,7 @@ import {
} from "react-native"; } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight"; import { highlightDiffLines } from "@/utils/diff-highlight";
@@ -756,7 +757,15 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
); );
} }
export function ToolCallDetailsContent({ export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
return (
<AppearanceStyleBoundary>
<ToolCallDetailsContentInner {...props} />
</AppearanceStyleBoundary>
);
}
function ToolCallDetailsContentInner({
detail, detail,
errorText, errorText,
maxHeight, maxHeight,

View File

@@ -240,7 +240,7 @@ describe("appearance settings", () => {
expect(result.monoFontFamily).toBe(""); expect(result.monoFontFamily).toBe("");
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE); expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("github"); expect(result.syntaxTheme).toBe("one");
}); });
it("clamps the UI font size into range and rejects non-numeric values", async () => { it("clamps the UI font size into range and rejects non-numeric values", async () => {
@@ -346,7 +346,7 @@ describe("appearance settings", () => {
}), }),
}); });
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github"); expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one");
}); });
it("drops an unknown syntax theme id back to the default", async () => { it("drops an unknown syntax theme id back to the default", async () => {
@@ -356,7 +356,7 @@ describe("appearance settings", () => {
}), }),
}); });
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github"); expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one");
}); });
}); });

View File

@@ -33,7 +33,7 @@ export interface AppSettings {
monoFontFamily: string; // "" = platform default mono stack monoFontFamily: string; // "" = platform default mono stack
uiFontSize: number; // clamped px, default 16 uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12 codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "auto" syntaxTheme: SyntaxThemeId; // default "one"
} }
export interface Settings extends AppSettings { export interface Settings extends AppSettings {
@@ -50,7 +50,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
monoFontFamily: "", monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE, uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE, codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "github", syntaxTheme: "one",
}; };
export const DEFAULT_APP_SETTINGS: Settings = { export const DEFAULT_APP_SETTINGS: Settings = {

View File

@@ -32,6 +32,7 @@ import {
THEME_SWATCHES, THEME_SWATCHES,
type Theme, type Theme,
} from "@/styles/theme"; } from "@/styles/theme";
import { isNative } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview"; import { AppearancePreview } from "./appearance-preview";
@@ -247,6 +248,7 @@ interface FontSizeRowProps {
title: string; title: string;
accessibilityLabel: string; accessibilityLabel: string;
draft: string; draft: string;
withBorder?: boolean;
onChangeDraft: (value: string) => void; onChangeDraft: (value: string) => void;
onCommit: () => void; onCommit: () => void;
} }
@@ -255,11 +257,12 @@ function FontSizeRow({
title, title,
accessibilityLabel, accessibilityLabel,
draft, draft,
withBorder = true,
onChangeDraft, onChangeDraft,
onCommit, onCommit,
}: FontSizeRowProps) { }: FontSizeRowProps) {
return ( return (
<View style={styles.rowWithBorder}> <View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text> <Text style={settingsStyles.rowTitle}>{title}</Text>
</View> </View>
@@ -348,6 +351,7 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
export function AppearanceSection() { export function AppearanceSection() {
const { settings, updateSettings } = useAppSettings(); const { settings, updateSettings } = useAppSettings();
const showFontFamilyRows = !isNative;
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily); const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily); const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
@@ -458,35 +462,40 @@ export function AppearanceSection() {
</SettingsSection> </SettingsSection>
<SettingsSection title="Fonts"> <SettingsSection title="Fonts">
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<FontFamilyRow {showFontFamilyRows ? (
title="Interface font" <FontFamilyRow
hint="Used across the app. Leave empty for the system default" title="Interface font"
accessibilityLabel="Interface font family" hint="Used across the app. Leave empty for the system default"
placeholder={UI_FONT_PLACEHOLDER} accessibilityLabel="Interface font family"
value={settings.uiFontFamily} placeholder={UI_FONT_PLACEHOLDER}
draft={uiFontDraft} value={settings.uiFontFamily}
withBorder={false} draft={uiFontDraft}
onChangeDraft={setUiFontDraft} withBorder={false}
onCommit={commitUiFontFamily} onChangeDraft={setUiFontDraft}
/> onCommit={commitUiFontFamily}
/>
) : null}
<FontSizeRow <FontSizeRow
title="Interface size" title="Interface size"
accessibilityLabel="Interface font size" accessibilityLabel="Interface font size"
draft={uiSizeDraft} draft={uiSizeDraft}
withBorder={showFontFamilyRows}
onChangeDraft={handleUiSizeChange} onChangeDraft={handleUiSizeChange}
onCommit={commitUiSize} onCommit={commitUiSize}
/> />
<FontFamilyRow {showFontFamilyRows ? (
title="Code font" <FontFamilyRow
hint="Used in code, diffs, and the terminal output. Leave empty for the system default" title="Code font"
accessibilityLabel="Code font family" hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
placeholder={MONO_FONT_PLACEHOLDER} accessibilityLabel="Code font family"
value={settings.monoFontFamily} placeholder={MONO_FONT_PLACEHOLDER}
draft={monoFontDraft} value={settings.monoFontFamily}
withBorder draft={monoFontDraft}
onChangeDraft={setMonoFontDraft} withBorder
onCommit={commitMonoFontFamily} onChangeDraft={setMonoFontDraft}
/> onCommit={commitMonoFontFamily}
/>
) : null}
<FontSizeRow <FontSizeRow
title="Code size" title="Code size"
accessibilityLabel="Code font size" accessibilityLabel="Code font size"

View File

@@ -68,7 +68,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
monoFontFamily: "", monoFontFamily: "",
uiFontSize: 16, uiFontSize: 16,
codeFontSize: 12, codeFontSize: 12,
syntaxTheme: "github", syntaxTheme: "one",
...overrides, ...overrides,
}; };
} }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createMarkdownStyles } from "./markdown-styles"; import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-styles";
import { darkTheme } from "./theme"; import { darkTheme } from "./theme";
describe("createMarkdownStyles", () => { describe("createMarkdownStyles", () => {
@@ -69,4 +69,28 @@ describe("createMarkdownStyles", () => {
userSelect: "text", userSelect: "text",
}); });
}); });
it("uses the mono font-size token directly for inline and block code", () => {
const styles = createMarkdownStyles(darkTheme);
const compactStyles = createCompactMarkdownStyles(darkTheme);
expect(styles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
expect(styles.code_block).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
});
expect(styles.fence).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
});
expect(compactStyles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
});
}); });

View File

@@ -172,6 +172,7 @@ export function createMarkdownStyles(theme: Theme) {
borderWidth: 0, borderWidth: 0,
fontFamily: theme.fontFamily.mono, fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code, fontSize: theme.fontSize.code,
lineHeight: Math.round(theme.fontSize.code * 1.45),
}, },
code_block: { code_block: {
@@ -389,7 +390,7 @@ export function createCompactMarkdownStyles(theme: Theme) {
code_inline: { code_inline: {
...baseStyles.code_inline, ...baseStyles.code_inline,
fontSize: theme.fontSize.sm - 3, fontSize: theme.fontSize.code,
}, },
code_block: { code_block: {

View File

@@ -25,6 +25,8 @@
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"dependencies": { "dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0", "@lezer/css": "^1.3.0",

View File

@@ -30,6 +30,22 @@ describe("highlightCode", () => {
expect(stringToken?.style).toBe("string"); expect(stringToken?.style).toBe("string");
}); });
it("highlights Swift code", () => {
const code = 'struct Greeter {\n let message = "hello"\n}';
const result = highlightCode(code, "test.swift");
expect(result).toHaveLength(3);
const structToken = result[0].find((t) => t.text === "struct");
expect(structToken?.style).toBe("keyword");
const typeToken = result[0].find((t) => t.text === "Greeter");
expect(typeToken?.style).toBe("definition");
const stringToken = result[1].find((t) => t.text.includes("hello"));
expect(stringToken?.style).toBe("string");
});
it("highlights TSX code with correct dialect", () => { it("highlights TSX code with correct dialect", () => {
const code = 'const el = <div className="test">hello</div>;'; const code = 'const el = <div className="test">hello</div>;';
const result = highlightCode(code, "test.tsx"); const result = highlightCode(code, "test.tsx");

View File

@@ -13,6 +13,7 @@ describe("isLanguageSupported", () => {
expect(isLanguageSupported("test.css")).toBe(true); expect(isLanguageSupported("test.css")).toBe(true);
expect(isLanguageSupported("test.html")).toBe(true); expect(isLanguageSupported("test.html")).toBe(true);
expect(isLanguageSupported("test.java")).toBe(true); expect(isLanguageSupported("test.java")).toBe(true);
expect(isLanguageSupported("test.swift")).toBe(true);
expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("test.ex")).toBe(true);
}); });
@@ -49,6 +50,7 @@ describe("getSupportedExtensions", () => {
expect(extensions).toContain("py"); expect(extensions).toContain("py");
expect(extensions).toContain("go"); expect(extensions).toContain("go");
expect(extensions).toContain("rs"); expect(extensions).toContain("rs");
expect(extensions).toContain("swift");
expect(extensions).toContain("json"); expect(extensions).toContain("json");
}); });
}); });

View File

@@ -59,10 +59,19 @@ describe("resolveSyntaxColors", () => {
}); });
it("dark-only themes ignore the color scheme", () => { it("dark-only themes ignore the color scheme", () => {
for (const id of ["dracula", "nord"] as const) { for (const id of ["dracula"] as const) {
expect(resolveSyntaxColors(id, "light")).toEqual(resolveSyntaxColors(id, "dark")); expect(resolveSyntaxColors(id, "light")).toEqual(resolveSyntaxColors(id, "dark"));
} }
}); });
it("nord uses a dark text palette in light mode", () => {
const light = resolveSyntaxColors("nord", "light");
const dark = resolveSyntaxColors("nord", "dark");
expect(light).not.toEqual(dark);
expect(light.variable).toBe("#2e3440");
expect(light.comment).toBe("#6b7280");
});
}); });
describe("isSyntaxThemeId", () => { describe("isSyntaxThemeId", () => {

View File

@@ -1,3 +1,5 @@
import { StreamLanguage } from "@codemirror/language";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { parser as jsParser } from "@lezer/javascript"; import { parser as jsParser } from "@lezer/javascript";
import { parser as jsonParser } from "@lezer/json"; import { parser as jsonParser } from "@lezer/json";
import { parser as cssParser } from "@lezer/css"; import { parser as cssParser } from "@lezer/css";
@@ -55,6 +57,8 @@ const parsersByExtension: Record<string, Parser> = {
yml: yamlParser, yml: yamlParser,
// Rust // Rust
rs: rustParser, rs: rustParser,
// Swift
swift: StreamLanguage.define(swift).parser,
// Elixir // Elixir
ex: elixirParser, ex: elixirParser,
exs: elixirParser, exs: elixirParser,

View File

@@ -180,8 +180,20 @@ const oneDark: RolePalette = {
operator: "#56b6c2", operator: "#56b6c2",
}; };
// --- Nord (dark only) ---------------------------------------------------- // --- Nord (Snow Storm light / Polar Night dark) ---------------------------
const nord: RolePalette = { const nordLight: RolePalette = {
base: "#2e3440",
keyword: "#5e81ac",
comment: "#6b7280",
string: "#4f6f3a",
number: "#8f5e91",
function: "#2e6f8e",
type: "#3b7f87",
tag: "#5e81ac",
attribute: "#8f5e91",
operator: "#5e81ac",
};
const nordDark: RolePalette = {
base: "#d8dee9", base: "#d8dee9",
keyword: "#81a1c1", keyword: "#81a1c1",
comment: "#616e88", comment: "#616e88",
@@ -269,7 +281,7 @@ export function resolveSyntaxColors(
case "one": case "one":
return expandRolePalette(dark ? oneDark : oneLight); return expandRolePalette(dark ? oneDark : oneLight);
case "nord": case "nord":
return expandRolePalette(nord); return expandRolePalette(dark ? nordDark : nordLight);
case "gruvbox": case "gruvbox":
return expandRolePalette(dark ? gruvboxDark : gruvboxLight); return expandRolePalette(dark ? gruvboxDark : gruvboxLight);
case "solarized": case "solarized":