@vvedantb/native-ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ai/trace.tsx CHANGED
@@ -7,7 +7,7 @@ import { palette } from "../palette";
7
7
  import { useLargerText } from "../theme";
8
8
  import { tokens } from "../tokens";
9
9
  import { useScheme } from "../useScheme";
10
- import { chatTextSize } from "./chat-text";
10
+ import { chatTextSize, formatElapsed } from "./chat-text";
11
11
  import {
12
12
  Collapsible,
13
13
  CollapsibleChevron,
@@ -232,3 +232,34 @@ export function AgentTraceCollapsibleContent({
232
232
  </CollapsibleContent>
233
233
  );
234
234
  }
235
+
236
+ export type AgentTraceFoldProps = Omit<CollapsibleProps, "children"> & {
237
+ /** How long the turn ran, in seconds. The title reads "Worked for 1m 5s". */
238
+ seconds: number;
239
+ /** The settled turn's steps, shown in an `AgentTrace` when open. */
240
+ children: ReactNode;
241
+ };
242
+
243
+ /**
244
+ * A settled turn folded behind one line, as the web `ActivityTasks` in its
245
+ * folded (Codex-style) mode: "Worked for …" over a hairline
246
+ * (`border-b border-border pb-1.5`), closed by default, and the steps in an
247
+ * `AgentTrace` 8 below when open.
248
+ */
249
+ export function AgentTraceFold({ seconds, children, defaultOpen = false, className, ...props }: AgentTraceFoldProps) {
250
+ const scheme = useScheme();
251
+ return (
252
+ <Collapsible className={cn("w-full min-w-0", className)} defaultOpen={defaultOpen} {...props}>
253
+ <View style={{ borderBottomWidth: 1, borderBottomColor: tokens.color.border[scheme], paddingBottom: 6 }}>
254
+ <AgentTraceCollapsibleTrigger>
255
+ <AgentTraceLine>
256
+ <AgentTraceTitle>{`Worked for ${formatElapsed(seconds)}`}</AgentTraceTitle>
257
+ </AgentTraceLine>
258
+ </AgentTraceCollapsibleTrigger>
259
+ </View>
260
+ <CollapsibleContent>
261
+ <AgentTrace className="mt-2">{children}</AgentTrace>
262
+ </CollapsibleContent>
263
+ </Collapsible>
264
+ );
265
+ }
@@ -0,0 +1,60 @@
1
+ import { palette, type Palette } from "./palette";
2
+ import { tokens, type Tokens } from "./tokens";
3
+
4
+ /** `tokens` and `palette` value types with the literal hex widened to `string` and `readonly` dropped. */
5
+ type Widen<T> = T extends string ? string : { -readonly [K in keyof T]: Widen<T[K]> };
6
+
7
+ /** The `tokens.color` shape: each key a `{ light, dark }` pair, except `white` and `black`. */
8
+ export type ThemeColors = Widen<Tokens["color"]>;
9
+
10
+ /** The `palette` shape: each key a `{ light, dark }` pair, except `destructive`. */
11
+ export type ThemePalette = Widen<Palette>;
12
+
13
+ export type ThemeConfig = {
14
+ /** Overrides for `tokens.color` (neutrals and surfaces). Keys not given keep the package value. */
15
+ color?: Partial<ThemeColors>;
16
+ /** Overrides for `palette` (primary, danger, overlay and the other web semantic colours). */
17
+ palette?: Partial<ThemePalette>;
18
+ };
19
+
20
+ type ColorValue = string | { light: string; dark: string };
21
+
22
+ /**
23
+ * Copies each given key onto `target`. A `{ light, dark }` pair is copied into
24
+ * the existing pair object, so values that hold the pair (`floatingFill`,
25
+ * `floatingHairline`, `floatingSolidFill`) follow it.
26
+ */
27
+ function assignColors(
28
+ target: { readonly [key: string]: ColorValue },
29
+ source: { readonly [key: string]: ColorValue | undefined },
30
+ ) {
31
+ for (const [key, value] of Object.entries(source)) {
32
+ if (value === undefined) continue;
33
+ const current = target[key];
34
+ if (typeof current === "object" && typeof value === "object") Object.assign(current, value);
35
+ else Object.assign(target, { [key]: value });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Sets the app's own colours. It writes into the shared `tokens.color` and
41
+ * `palette` objects, which every component reads at render, so call it once at
42
+ * app start, before the first render (for example at the top of the root
43
+ * `_layout.tsx`, outside the component). A call after the first render does not
44
+ * redraw mounted components. Each pair gives both schemes:
45
+ *
46
+ * ```ts
47
+ * configureTheme({
48
+ * color: { background: { light: "#f4f5f6", dark: "#020202" } },
49
+ * palette: { primary: { light: "#09090b", dark: "#fafafa" } },
50
+ * });
51
+ * ```
52
+ *
53
+ * Only the JS colours change. NativeWind classes keep the app's Tailwind theme
54
+ * (`tailwind-preset` reads the package values from source), and constants copied
55
+ * out before the call (`NEUTRAL_500`) keep the package value.
56
+ */
57
+ export function configureTheme(config: ThemeConfig): void {
58
+ if (config.color) assignColors(tokens.color, config.color);
59
+ if (config.palette) assignColors(palette, config.palette);
60
+ }
@@ -0,0 +1,5 @@
1
+ // `@vvedantb/native-ui/markdown`: `Markdown`, chat markdown styled as the web
2
+ // `.markdown` block. Not on the root entry: it imports the optional peer
3
+ // `react-native-marked`, and Metro does not tree-shake.
4
+ export { Markdown, markdownStyles, inlineCodeStyle, MARKDOWN_SCALE, MARKDOWN_LIST } from "../markdown/Markdown";
5
+ export type { MarkdownProps } from "../markdown/Markdown";
@@ -59,6 +59,8 @@ export type {
59
59
  TabsContentProps,
60
60
  } from "../Tabs";
61
61
  export { ListRow } from "../ListRow";
62
+ export { DividedList } from "../DividedList";
63
+ export type { DividedListProps } from "../DividedList";
62
64
  export type { ListRowProps } from "../ListRow";
63
65
  export { HeaderIconButton } from "../HeaderIconButton";
64
66
  export { ThemeToggle } from "../ThemeToggle";
@@ -100,6 +102,26 @@ export {
100
102
  } from "../AlertDialog";
101
103
  export type { AlertDialogProps, AlertDialogContentProps } from "../AlertDialog";
102
104
  export { ConfirmDialog } from "../ConfirmDialog";
105
+ export { BulkActionBar, BULK_BAR_LABEL_MIN_WIDTH } from "../BulkActionBar";
106
+ export type { BulkActionBarProps, BulkBarAction } from "../BulkActionBar";
107
+ export {
108
+ DropdownMenu,
109
+ DropdownMenuTrigger,
110
+ DropdownMenuContent,
111
+ DropdownMenuLabel,
112
+ DropdownMenuItem,
113
+ DropdownMenuRadioItem,
114
+ DropdownMenuSeparator,
115
+ } from "../DropdownMenu";
116
+ export type {
117
+ DropdownMenuProps,
118
+ DropdownMenuTriggerProps,
119
+ DropdownMenuContentProps,
120
+ DropdownMenuAlign,
121
+ DropdownMenuLabelProps,
122
+ DropdownMenuItemProps,
123
+ DropdownMenuRadioItemProps,
124
+ } from "../DropdownMenu";
103
125
  export type { ConfirmDialogProps } from "../ConfirmDialog";
104
126
  export {
105
127
  Sheet,
@@ -11,5 +11,7 @@ export {
11
11
  export type { NativeUiTheme } from "../theme";
12
12
  export { palette, withAlpha } from "../palette";
13
13
  export type { Palette } from "../palette";
14
+ export { configureTheme } from "../configureTheme";
15
+ export type { ThemeConfig, ThemeColors, ThemePalette } from "../configureTheme";
14
16
  export { fontClass } from "../typography";
15
17
  export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "../colors";
@@ -7,7 +7,9 @@ import { useState, type ReactNode } from "react";
7
7
  import { ScrollView, Text, View, type TextProps } from "react-native";
8
8
  import {
9
9
  IconCopy,
10
+ IconDots,
10
11
  IconInbox,
12
+ IconPencil,
11
13
  IconPhoto,
12
14
  IconRefresh,
13
15
  IconTrash,
@@ -28,6 +30,14 @@ import {
28
30
  AmbientBackground,
29
31
  AppMark,
30
32
  AppWordmark,
33
+ BulkActionBar,
34
+ DropdownMenu,
35
+ DropdownMenuContent,
36
+ DropdownMenuItem,
37
+ DropdownMenuLabel,
38
+ DropdownMenuRadioItem,
39
+ DropdownMenuSeparator,
40
+ DropdownMenuTrigger,
31
41
  Avatar,
32
42
  AvatarFallback,
33
43
  Badge,
@@ -226,6 +236,8 @@ function GalleryScreen() {
226
236
  const [muted, setMuted] = useState(false);
227
237
  const [sort, setSort] = useState<"newest" | "name">("newest");
228
238
  const [page, setPage] = useState(0);
239
+ const [menuSort, setMenuSort] = useState<"newest" | "oldest">("newest");
240
+ const [picked, setPicked] = useState(2);
229
241
  const scheme = useScheme();
230
242
 
231
243
  return (
@@ -563,6 +575,50 @@ function GalleryScreen() {
563
575
  </View>
564
576
  </Section>
565
577
 
578
+ <Section title="DropdownMenu and BulkActionBar">
579
+ <View className="flex-row justify-end">
580
+ <DropdownMenu>
581
+ <DropdownMenuTrigger
582
+ accessibilityLabel="List options"
583
+ hitSlop={6}
584
+ className="h-8 w-8 items-center justify-center"
585
+ >
586
+ <IconDots size={18} color={tokens.color.muted[scheme]} />
587
+ </DropdownMenuTrigger>
588
+ <DropdownMenuContent align="end">
589
+ <DropdownMenuLabel>Sort</DropdownMenuLabel>
590
+ <DropdownMenuRadioItem checked={menuSort === "newest"} onSelect={() => setMenuSort("newest")}>
591
+ Newest first
592
+ </DropdownMenuRadioItem>
593
+ <DropdownMenuRadioItem checked={menuSort === "oldest"} onSelect={() => setMenuSort("oldest")}>
594
+ Oldest first
595
+ </DropdownMenuRadioItem>
596
+ <DropdownMenuSeparator />
597
+ <DropdownMenuItem icon={IconPencil} onSelect={() => toast("Rename")}>
598
+ Rename
599
+ </DropdownMenuItem>
600
+ <DropdownMenuItem icon={IconTrash} destructive onSelect={() => toast.error("Deleted")}>
601
+ Delete
602
+ </DropdownMenuItem>
603
+ </DropdownMenuContent>
604
+ </DropdownMenu>
605
+ </View>
606
+ <View style={{ height: 88 }}>
607
+ <BulkActionBar
608
+ isSelecting
609
+ selectedCount={picked}
610
+ totalCount={5}
611
+ bottomOffset={0}
612
+ onExitSelect={() => setPicked(0)}
613
+ onSelectAll={() => setPicked(5)}
614
+ onClearSelection={() => setPicked(0)}
615
+ primaryActions={[{ key: "copy", label: "Copy items", shortLabel: "Copy", icon: IconCopy, onClick: () => toast("Copied") }]}
616
+ moreActions={[{ key: "rename", label: "Rename", icon: IconPencil, onClick: () => toast("Rename") }]}
617
+ destructiveAction={{ key: "delete", label: "Delete items", shortLabel: "Delete", icon: IconTrash, destructive: true, onClick: () => toast.error("Deleted") }}
618
+ />
619
+ </View>
620
+ </Section>
621
+
566
622
  <Section title="EmptyState">
567
623
  <EmptyState title="Nothing yet" description="Items you add show here." compact />
568
624
  <EmptyState.Root variant="panel">
package/src/floating.ts CHANGED
@@ -45,10 +45,8 @@ export const floatingHairline = tokens.color.hairline;
45
45
 
46
46
  export const floatingFill = tokens.color.fill;
47
47
 
48
- export const floatingSolidFill = {
49
- light: tokens.color.surface.light,
50
- dark: tokens.color.surface.dark,
51
- } as const;
48
+ /** The `tokens.color.surface` pair itself, so `configureTheme` reaches it. */
49
+ export const floatingSolidFill = tokens.color.surface;
52
50
 
53
51
  export function tabBarReserve(largerText: boolean, bottomInset: number) {
54
52
  const bar = largerText
@@ -0,0 +1,117 @@
1
+ import type * as PrismReactRenderer from "prism-react-renderer";
2
+
3
+ export type PrismReactRendererModule = typeof PrismReactRenderer;
4
+
5
+ let prismReactRenderer: PrismReactRendererModule | null | undefined;
6
+
7
+ /**
8
+ * `prism-react-renderer`, or `null` when the optional peer is not installed.
9
+ * `CodeBlock` highlights with it. Metro bundles a `require` placed directly in
10
+ * a `try` block as an optional dependency, so a missing peer does not break
11
+ * the bundle.
12
+ */
13
+ export function loadPrismReactRenderer(): PrismReactRendererModule | null {
14
+ if (prismReactRenderer === undefined) {
15
+ try {
16
+ prismReactRenderer = require("prism-react-renderer");
17
+ } catch {
18
+ prismReactRenderer = null;
19
+ }
20
+ }
21
+ // `?? null`: with the real peer types installed, TypeScript keeps `undefined`
22
+ // in the type after the `try` (the package typecheck only sees a stub).
23
+ return prismReactRenderer ?? null;
24
+ }
25
+
26
+ export type CodeColorRole =
27
+ | "plain"
28
+ | "comment"
29
+ | "keyword"
30
+ | "string"
31
+ | "constant"
32
+ | "function"
33
+ | "tag"
34
+ | "variable"
35
+ | "inserted"
36
+ | "deleted";
37
+
38
+ /**
39
+ * The shiki `github-light` and `github-dark` colours the web `CodeBlock`
40
+ * uses, by role. Plain text is the theme foreground.
41
+ */
42
+ export const GITHUB_CODE_COLORS: Record<"light" | "dark", Record<CodeColorRole, string>> = {
43
+ light: {
44
+ plain: "#24292e",
45
+ comment: "#6a737d",
46
+ keyword: "#d73a49",
47
+ string: "#032f62",
48
+ constant: "#005cc5",
49
+ function: "#6f42c1",
50
+ tag: "#22863a",
51
+ variable: "#e36209",
52
+ inserted: "#22863a",
53
+ deleted: "#b31d28",
54
+ },
55
+ dark: {
56
+ plain: "#e1e4e8",
57
+ comment: "#6a737d",
58
+ keyword: "#f97583",
59
+ string: "#9ecbff",
60
+ constant: "#79b8ff",
61
+ function: "#b392f0",
62
+ tag: "#85e89d",
63
+ variable: "#ffab70",
64
+ inserted: "#85e89d",
65
+ deleted: "#fdaeb7",
66
+ },
67
+ };
68
+
69
+ /** Prism token types to their GitHub role. Types not listed are plain. */
70
+ const PRISM_ROLE: Record<string, CodeColorRole> = {
71
+ comment: "comment",
72
+ prolog: "comment",
73
+ doctype: "comment",
74
+ cdata: "comment",
75
+ keyword: "keyword",
76
+ operator: "keyword",
77
+ atrule: "keyword",
78
+ important: "keyword",
79
+ rule: "keyword",
80
+ string: "string",
81
+ char: "string",
82
+ "attr-value": "string",
83
+ regex: "string",
84
+ url: "string",
85
+ "template-string": "string",
86
+ number: "constant",
87
+ boolean: "constant",
88
+ constant: "constant",
89
+ property: "constant",
90
+ "attr-name": "constant",
91
+ symbol: "constant",
92
+ builtin: "constant",
93
+ entity: "constant",
94
+ function: "function",
95
+ "function-variable": "function",
96
+ "class-name": "function",
97
+ maybe_class_name: "function",
98
+ tag: "tag",
99
+ selector: "tag",
100
+ variable: "variable",
101
+ parameter: "variable",
102
+ inserted: "inserted",
103
+ deleted: "deleted",
104
+ punctuation: "plain",
105
+ plain: "plain",
106
+ };
107
+
108
+ /**
109
+ * The colour of a Prism token from its types. Later types win, as in
110
+ * `prism-react-renderer`'s theme lookup: the `<` of a tag (`["tag",
111
+ * "punctuation"]`) is plain.
112
+ */
113
+ export function codeTokenColor(types: readonly string[], scheme: "light" | "dark"): string {
114
+ let role: CodeColorRole = "plain";
115
+ for (const type of types) role = PRISM_ROLE[type] ?? role;
116
+ return GITHUB_CODE_COLORS[scheme][role];
117
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export {
10
10
  export type { NativeUiTheme } from "./theme";
11
11
  export { palette, withAlpha } from "./palette";
12
12
  export type { Palette } from "./palette";
13
+ export { configureTheme } from "./configureTheme";
14
+ export type { ThemeConfig, ThemeColors, ThemePalette } from "./configureTheme";
13
15
  export { fontClass } from "./typography";
14
16
  export {
15
17
  FLOATING_INSET,
@@ -93,6 +95,8 @@ export type {
93
95
  TabsContentProps,
94
96
  } from "./Tabs";
95
97
  export { ListRow } from "./ListRow";
98
+ export { DividedList } from "./DividedList";
99
+ export type { DividedListProps } from "./DividedList";
96
100
  export type { ListRowProps } from "./ListRow";
97
101
  export { HeaderIconButton } from "./HeaderIconButton";
98
102
  export { ThemeToggle } from "./ThemeToggle";
@@ -134,6 +138,26 @@ export {
134
138
  } from "./AlertDialog";
135
139
  export type { AlertDialogProps, AlertDialogContentProps } from "./AlertDialog";
136
140
  export { ConfirmDialog } from "./ConfirmDialog";
141
+ export { BulkActionBar, BULK_BAR_LABEL_MIN_WIDTH } from "./BulkActionBar";
142
+ export type { BulkActionBarProps, BulkBarAction } from "./BulkActionBar";
143
+ export {
144
+ DropdownMenu,
145
+ DropdownMenuTrigger,
146
+ DropdownMenuContent,
147
+ DropdownMenuLabel,
148
+ DropdownMenuItem,
149
+ DropdownMenuRadioItem,
150
+ DropdownMenuSeparator,
151
+ } from "./DropdownMenu";
152
+ export type {
153
+ DropdownMenuProps,
154
+ DropdownMenuTriggerProps,
155
+ DropdownMenuContentProps,
156
+ DropdownMenuAlign,
157
+ DropdownMenuLabelProps,
158
+ DropdownMenuItemProps,
159
+ DropdownMenuRadioItemProps,
160
+ } from "./DropdownMenu";
137
161
  export type { ConfirmDialogProps } from "./ConfirmDialog";
138
162
  export {
139
163
  Sheet,
@@ -0,0 +1,191 @@
1
+ import { useContext, type ReactNode } from "react";
2
+ import { Text, View, type TextStyle, type ViewStyle } from "react-native";
3
+ import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
4
+ import { CodeBlock } from "../ai/code-block";
5
+ import { MONO_FONT, chatTextSize } from "../ai/chat-text";
6
+ import { ChatTextColorContext } from "../ai/response-text";
7
+ import { sizeStyle } from "../controlStyles";
8
+ import { palette, withAlpha } from "../palette";
9
+ import { useLargerText } from "../theme";
10
+ import { tokens } from "../tokens";
11
+ import { useScheme } from "../useScheme";
12
+
13
+ /** Instrument Sans faces, as the app loads them (see `INSTRUMENT_SANS` in tailwind-preset.cjs). */
14
+ const FONT = {
15
+ regular: "InstrumentSans_400Regular",
16
+ semibold: "InstrumentSans_600SemiBold",
17
+ } as const;
18
+
19
+ /**
20
+ * Sizes from the web `markdown.css` (`@vvedantb/ui/tokens/css/markdown.css`),
21
+ * in em of the body size: h1 1.43, h2 1.29, h3 1.14, h4-h6 1; inline code
22
+ * 0.86. Headings line-height 1.3; blocks 0.65rem apart.
23
+ */
24
+ export const MARKDOWN_SCALE = {
25
+ h1: 1.43,
26
+ h2: 1.29,
27
+ h3: 1.14,
28
+ h4: 1,
29
+ inlineCode: 0.86,
30
+ headingLineHeight: 1.3,
31
+ blockGap: 10,
32
+ } as const;
33
+
34
+ export type MarkdownProps = {
35
+ /** The markdown source. */
36
+ children: string;
37
+ /** With it, fenced code blocks get a copy button (native-ui adds no clipboard package). */
38
+ onCopyCode?: (code: string) => void | Promise<void>;
39
+ };
40
+
41
+ /**
42
+ * Markdown at chat size, styled as the web `.markdown` (Streamdown) block:
43
+ * compact headings, primary links underlined at 30%, inline code on a 6%
44
+ * foreground wash at radius 6, and fenced code as `CodeBlock` (the flat
45
+ * `surface-secondary` panel with a language and copy header). Text takes the
46
+ * enclosing `MessageContent` colour, like `ResponseText`.
47
+ *
48
+ * Needs the optional peer `react-native-marked` (it renders through
49
+ * `useMarkdown`, so it nests in a `ScrollView` without a `FlatList`).
50
+ */
51
+ export function Markdown({ children, onCopyCode }: MarkdownProps) {
52
+ const scheme = useScheme();
53
+ const largerText = useLargerText();
54
+ const inherited = useContext(ChatTextColorContext);
55
+ const body = sizeStyle(chatTextSize("body", largerText));
56
+ const color = inherited ?? tokens.color.foreground[scheme];
57
+ const renderer = new NativeUiMarkdownRenderer(scheme, body.fontSize, onCopyCode);
58
+ const elements = useMarkdown(children, {
59
+ colorScheme: scheme,
60
+ renderer,
61
+ theme: {
62
+ colors: {
63
+ text: color,
64
+ link: palette.primary[scheme],
65
+ code: tokens.color.surfaceSecondary[scheme],
66
+ border: tokens.color.border[scheme],
67
+ },
68
+ },
69
+ styles: markdownStyles(scheme, body, color),
70
+ });
71
+ return <>{elements}</>;
72
+ }
73
+
74
+ /** The style map `useMarkdown` takes, from the web scale. Exported for tests. */
75
+ export function markdownStyles(
76
+ scheme: "light" | "dark",
77
+ body: { fontSize: number; lineHeight: number },
78
+ color: string,
79
+ ): MarkedStyles {
80
+ const heading = (scale: number): TextStyle => ({
81
+ fontFamily: FONT.semibold,
82
+ fontWeight: "600",
83
+ fontSize: Math.round(body.fontSize * scale),
84
+ lineHeight: Math.round(body.fontSize * scale * MARKDOWN_SCALE.headingLineHeight),
85
+ letterSpacing: -0.01 * body.fontSize * scale,
86
+ color,
87
+ marginTop: 20,
88
+ marginBottom: 8,
89
+ });
90
+ const gap = { marginVertical: MARKDOWN_SCALE.blockGap / 2 };
91
+ return {
92
+ text: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
93
+ paragraph: { ...gap, paddingVertical: 0 },
94
+ strong: { fontFamily: FONT.semibold, fontWeight: "600" },
95
+ em: { fontStyle: "italic" },
96
+ link: {
97
+ color: palette.primary[scheme],
98
+ textDecorationLine: "underline",
99
+ textDecorationColor: withAlpha(palette.primary[scheme], 0.3),
100
+ },
101
+ h1: heading(MARKDOWN_SCALE.h1),
102
+ h2: heading(MARKDOWN_SCALE.h2),
103
+ h3: heading(MARKDOWN_SCALE.h3),
104
+ h4: heading(MARKDOWN_SCALE.h4),
105
+ h5: heading(MARKDOWN_SCALE.h4),
106
+ h6: { ...heading(MARKDOWN_SCALE.h4), color: tokens.color.muted[scheme] },
107
+ li: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
108
+ blockquote: {
109
+ ...gap,
110
+ paddingLeft: 13,
111
+ borderLeftWidth: 2,
112
+ borderLeftColor: tokens.color.border[scheme],
113
+ backgroundColor: "transparent",
114
+ },
115
+ hr: { marginVertical: 20, height: 1, backgroundColor: tokens.color.border[scheme] },
116
+ codespan: inlineCodeStyle(scheme, body.fontSize),
117
+ };
118
+ }
119
+
120
+ /** Web `.markdown-inline-code`: 0.86em mono on a 6% foreground wash, radius 6. */
121
+ export function inlineCodeStyle(scheme: "light" | "dark", fontSize: number): TextStyle {
122
+ return {
123
+ fontFamily: MONO_FONT,
124
+ fontStyle: "normal",
125
+ fontSize: Math.round(fontSize * MARKDOWN_SCALE.inlineCode),
126
+ backgroundColor: withAlpha(tokens.color.foreground[scheme], 0.06),
127
+ borderRadius: 6,
128
+ };
129
+ }
130
+
131
+ /** Web `.markdown` lists: 1.25rem (20) indent, 0.25rem (4) between items, muted markers (a 5-wide disc). */
132
+ export const MARKDOWN_LIST = { indent: 20, itemGap: 4, disc: 5 } as const;
133
+
134
+ /**
135
+ * `react-native-marked`'s renderer with native-ui code and lists: `CodeBlock`
136
+ * panels, inline-code chips, and lists drawn as the web `.markdown` lists
137
+ * (the library's own markers apply its marker style to every item).
138
+ */
139
+ class NativeUiMarkdownRenderer extends Renderer {
140
+ constructor(
141
+ private readonly scheme: "light" | "dark",
142
+ private readonly fontSize: number,
143
+ private readonly onCopyCode: MarkdownProps["onCopyCode"],
144
+ ) {
145
+ super();
146
+ }
147
+
148
+ override code(text: string, language?: string): ReactNode {
149
+ return <CodeBlock key={this.getKey()} code={text} language={language || undefined} onCopy={this.onCopyCode} />;
150
+ }
151
+
152
+ override codespan(text: string): ReactNode {
153
+ // Text in Text takes no padding: no-break spaces pad the chip and keep it on one line.
154
+ return (
155
+ <Text key={this.getKey()} selectable style={inlineCodeStyle(this.scheme, this.fontSize)}>
156
+ {`\u00A0${text}\u00A0`}
157
+ </Text>
158
+ );
159
+ }
160
+
161
+ override list(
162
+ ordered: boolean,
163
+ li: ReactNode[],
164
+ _listStyle?: ViewStyle,
165
+ textStyle?: TextStyle,
166
+ startIndex = 1,
167
+ ): ReactNode {
168
+ const muted = tokens.color.muted[this.scheme];
169
+ const lineHeight = typeof textStyle?.lineHeight === "number" ? textStyle.lineHeight : 24;
170
+ // A drawn disc: the font's bullet glyph is far smaller than the web `disc` marker.
171
+ const disc = MARKDOWN_LIST.disc;
172
+ return (
173
+ <View key={this.getKey()} style={{ marginVertical: MARKDOWN_SCALE.blockGap / 2, gap: MARKDOWN_LIST.itemGap }}>
174
+ {li.map((item, index) => (
175
+ <View key={index} style={{ flexDirection: "row" }}>
176
+ {ordered ? (
177
+ <Text style={[textStyle, { width: MARKDOWN_LIST.indent, color: muted, fontVariant: ["tabular-nums"] }]}>
178
+ {`${startIndex + index}.`}
179
+ </Text>
180
+ ) : (
181
+ <View style={{ width: MARKDOWN_LIST.indent, paddingTop: (lineHeight - disc) / 2 }}>
182
+ <View style={{ width: disc, height: disc, borderRadius: disc / 2, backgroundColor: muted }} />
183
+ </View>
184
+ )}
185
+ <View style={{ flex: 1 }}>{item}</View>
186
+ </View>
187
+ ))}
188
+ </View>
189
+ );
190
+ }
191
+ }