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`).
- **`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.
- **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`.
## Debugging

70
package-lock.json generated
View File

@@ -2813,6 +2813,50 @@
"integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==",
"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": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -7874,6 +7918,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -16979,6 +17029,12 @@
"dev": true,
"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": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -34033,6 +34089,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"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": {
"version": "1.1.21",
"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==",
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -37674,6 +37742,8 @@
"name": "@getpaseo/highlight",
"version": "0.1.87",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
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,
errorText,
maxHeight,

View File

@@ -240,7 +240,7 @@ describe("appearance settings", () => {
expect(result.monoFontFamily).toBe("");
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("github");
expect(result.syntaxTheme).toBe("one");
});
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 () => {
@@ -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
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "auto"
syntaxTheme: SyntaxThemeId; // default "one"
}
export interface Settings extends AppSettings {
@@ -50,7 +50,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "github",
syntaxTheme: "one",
};
export const DEFAULT_APP_SETTINGS: Settings = {

View File

@@ -32,6 +32,7 @@ import {
THEME_SWATCHES,
type Theme,
} from "@/styles/theme";
import { isNative } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
@@ -247,6 +248,7 @@ interface FontSizeRowProps {
title: string;
accessibilityLabel: string;
draft: string;
withBorder?: boolean;
onChangeDraft: (value: string) => void;
onCommit: () => void;
}
@@ -255,11 +257,12 @@ function FontSizeRow({
title,
accessibilityLabel,
draft,
withBorder = true,
onChangeDraft,
onCommit,
}: FontSizeRowProps) {
return (
<View style={styles.rowWithBorder}>
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
</View>
@@ -348,6 +351,7 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
export function AppearanceSection() {
const { settings, updateSettings } = useAppSettings();
const showFontFamilyRows = !isNative;
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
@@ -458,35 +462,40 @@ export function AppearanceSection() {
</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}
/>
{showFontFamilyRows ? (
<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}
/>
) : null}
<FontSizeRow
title="Interface size"
accessibilityLabel="Interface font size"
draft={uiSizeDraft}
withBorder={showFontFamilyRows}
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}
/>
{showFontFamilyRows ? (
<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}
/>
) : null}
<FontSizeRow
title="Code size"
accessibilityLabel="Code font size"

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createMarkdownStyles } from "./markdown-styles";
import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-styles";
import { darkTheme } from "./theme";
describe("createMarkdownStyles", () => {
@@ -69,4 +69,28 @@ describe("createMarkdownStyles", () => {
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,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: Math.round(theme.fontSize.code * 1.45),
},
code_block: {
@@ -389,7 +390,7 @@ export function createCompactMarkdownStyles(theme: Theme) {
code_inline: {
...baseStyles.code_inline,
fontSize: theme.fontSize.sm - 3,
fontSize: theme.fontSize.code,
},
code_block: {

View File

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

View File

@@ -30,6 +30,22 @@ describe("highlightCode", () => {
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", () => {
const code = 'const el = <div className="test">hello</div>;';
const result = highlightCode(code, "test.tsx");

View File

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

View File

@@ -59,10 +59,19 @@ describe("resolveSyntaxColors", () => {
});
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"));
}
});
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", () => {

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 jsonParser } from "@lezer/json";
import { parser as cssParser } from "@lezer/css";
@@ -55,6 +57,8 @@ const parsersByExtension: Record<string, Parser> = {
yml: yamlParser,
// Rust
rs: rustParser,
// Swift
swift: StreamLanguage.define(swift).parser,
// Elixir
ex: elixirParser,
exs: elixirParser,

View File

@@ -180,8 +180,20 @@ const oneDark: RolePalette = {
operator: "#56b6c2",
};
// --- Nord (dark only) ----------------------------------------------------
const nord: RolePalette = {
// --- Nord (Snow Storm light / Polar Night dark) ---------------------------
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",
keyword: "#81a1c1",
comment: "#616e88",
@@ -269,7 +281,7 @@ export function resolveSyntaxColors(
case "one":
return expandRolePalette(dark ? oneDark : oneLight);
case "nord":
return expandRolePalette(nord);
return expandRolePalette(dark ? nordDark : nordLight);
case "gruvbox":
return expandRolePalette(dark ? gruvboxDark : gruvboxLight);
case "solarized":