Files
paseo/packages/app/src/styles/markdown-styles.ts
T
Mohamed BoudraandGitHub a1c8e1a1f9 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
2026-05-31 16:09:01 +08:00

408 lines
11 KiB
TypeScript

import type { Theme } from "./theme";
import { isWeb } from "@/constants/platform";
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
/**
* Creates comprehensive markdown styles for react-native-markdown-display.
*
* Usage:
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
* <Markdown style={markdownStyles}>{content}</Markdown>
*/
export function createMarkdownStyles(theme: Theme) {
return {
// =========================================================================
// BASE STYLES
// =========================================================================
body: {
...webSelectableTextStyle,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
// Prose line-height scales with the UI ramp (≈22 at base 16), NOT the
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
lineHeight: Math.round(theme.fontSize.base * 1.4),
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
},
text: {
...webSelectableTextStyle,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
paragraph: {
marginTop: 0,
marginBottom: theme.spacing[3],
flexWrap: "wrap" as const,
flexDirection: "row" as const,
alignItems: "flex-start" as const,
justifyContent: "flex-start" as const,
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
},
// =========================================================================
// HEADINGS
// =========================================================================
heading1: {
...webSelectableTextStyle,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: 32,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading2: {
...webSelectableTextStyle,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: 28,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading3: {
...webSelectableTextStyle,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 26,
},
heading4: {
...webSelectableTextStyle,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 24,
},
heading5: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 22,
},
heading6: {
...webSelectableTextStyle,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 20,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
// =========================================================================
// TEXT FORMATTING
// =========================================================================
strong: {
...webSelectableTextStyle,
fontWeight: theme.fontWeight.medium,
},
em: {
...webSelectableTextStyle,
fontStyle: "italic" as const,
},
s: {
...webSelectableTextStyle,
textDecorationLine: "line-through" as const,
color: theme.colors.foregroundMuted,
},
link: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
blocklink: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
// =========================================================================
// CODE
// =========================================================================
code_inline: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
borderWidth: 0,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
code_block: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[2],
},
fence: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[3],
},
pre: {
marginVertical: theme.spacing[2],
},
// =========================================================================
// TABLES
// =========================================================================
table: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[3],
},
thead: {
backgroundColor: theme.colors.surface2,
},
tbody: {},
th: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderBottomWidth: 1,
borderRightWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
textAlign: "left" as const,
},
tr: {
borderBottomWidth: 1,
borderColor: theme.colors.border,
flexDirection: "row" as const,
},
td: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderRightWidth: 1,
borderColor: theme.colors.border,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
},
// =========================================================================
// LISTS
// =========================================================================
bullet_list: {
paddingLeft: 0,
width: "100%" as const,
},
ordered_list: {
paddingLeft: 0,
width: "100%" as const,
},
list_item: {
marginBottom: theme.spacing[1],
flexDirection: "row" as const,
alignItems: "flex-start" as const,
flexShrink: 1,
},
bullet_list_content: {
flex: 1,
flexShrink: 1,
},
ordered_list_content: {
flex: 1,
flexShrink: 1,
},
bullet_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
ordered_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: 22,
minWidth: 12,
},
// =========================================================================
// BLOCKQUOTE
// =========================================================================
blockquote: {
backgroundColor: theme.colors.surface2,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
marginVertical: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
// =========================================================================
// HORIZONTAL RULE
// =========================================================================
hr: {
backgroundColor: theme.colors.border,
height: 1,
marginVertical: theme.spacing[6],
},
// =========================================================================
// IMAGES
// =========================================================================
image: {
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
},
// =========================================================================
// BREAKS
// =========================================================================
hardbreak: {
height: theme.spacing[2],
},
softbreak: {},
};
}
/**
* Creates a smaller variant of markdown styles for compact UI elements
* like thought bubbles, tooltips, or side panels.
*/
export function createCompactMarkdownStyles(theme: Theme) {
const baseStyles = createMarkdownStyles(theme);
return {
...baseStyles,
body: {
...baseStyles.body,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
heading1: {
...baseStyles.heading1,
fontSize: theme.fontSize.xl,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: 26,
},
heading2: {
...baseStyles.heading2,
fontSize: theme.fontSize.lg,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[2],
lineHeight: 24,
},
heading3: {
...baseStyles.heading3,
fontSize: theme.fontSize.base,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 22,
},
paragraph: {
...baseStyles.paragraph,
marginBottom: theme.spacing[2],
},
code_inline: {
...baseStyles.code_inline,
fontSize: theme.fontSize.sm - 3,
},
code_block: {
...baseStyles.code_block,
fontSize: theme.fontSize.code,
padding: theme.spacing[2],
},
fence: {
...baseStyles.fence,
fontSize: theme.fontSize.code,
padding: theme.spacing[2],
},
};
}