@lotics/ui 5.4.0 → 5.5.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/AGENTS.md CHANGED
@@ -75,9 +75,10 @@ Pick by capability, not by name. (→ the source file for the API.)
75
75
  - **Files** — `FileDropzone`, `FileThumbnail` / `FileThumbnailGrid`, `FilePreview` /
76
76
  `FileGalleryModal`, `ImageGallery`; for gated CRUD compose locally with `useSelectionMode` +
77
77
  `shareOrDownloadFiles` + `rotateImageToBlob` (see §Data entry → Attachments).
78
- - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
79
- `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
80
- `comments_thread`.
78
+ - **Specialized work surfaces** — `ScanField` (scan/verify), `Stepper` (a guided run / progress
79
+ sequence done · current · upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow`
80
+ (allocation), `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
81
+ `Calendar`, `Gantt`, `comments_thread`.
81
82
  - **AI surfaces** — `Composer` (the adaptive command/chat composer — a compact pill when empty that
82
83
  expands for long text + attachments; the surface that triggers agent work),
83
84
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
@@ -264,9 +265,13 @@ Compose the surfaces as a loop, and reach for the right one by job:
264
265
  attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
265
266
  way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
266
267
  a morph).
267
- - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
268
- spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
269
- work, NEVER a bare spinner — the
268
+ - **Show the work** — `AgentRun`: a live feed of the agent's work its streamed narration (with a
269
+ blinking caret) + tool calls as a vertical `Stepper` of neutral dots (the latest pulsing while live,
270
+ a white-check dot once done). No title, no avatar just the work. A terminal "Done" step (the
271
+ `complete` node) appears ONLY once the run finishes. A step can carry `peek` content to glance into
272
+ via a popover. Tool labels resolve from the raw tool name via a built-in default map + an optional
273
+ `labelForTool` override (localize THERE — the kit stays English). `doneLabel` localizes the terminal.
274
+ Transparent work, NEVER a bare spinner — the
270
275
  intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
271
276
  collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
272
277
  morphs into it while running, and reveals again when done.
@@ -365,9 +370,11 @@ controls — while **status/severity/diffs use functional colour** the way the r
365
370
  - **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
366
371
  - **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
367
372
  emerald on-track (+ stacking order, most severe first).
368
- - **AgentRun** — `Timeline`-style tinted-disc nodes that fade in: a running step spins **blue**, a
369
- finished step is an **emerald check**, a failure a **red** alert; the header reads `Working` (blue
370
- spinner) / `Done` (emerald check) / `Stopped` (red).
373
+ - **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
374
+ centre), `done` a filled accent dot + **white check**, `upcoming` a faint **grey** ring, the terminal
375
+ `complete` a **blackish ring + black check**, `warning` **amber**. Default accent is neutral ink
376
+ (`color` themes it); `AgentRun` keeps the neutral default + appends the `complete` "Done" node only
377
+ when the run finishes.
371
378
  - **ChangeReview diffs** — the old value struck in **red**, the new value in **green**.
372
379
  - **Discrepancy** — the agent's pick gets a **blue** highlight; `Mismatch` reads amber, `Resolved`
373
380
  emerald.
@@ -584,8 +591,8 @@ inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s
584
591
  list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
585
592
  floating_action_bar · filter_pill · column_filter (ColumnFilter — the typed per-column filter pill +
586
593
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
587
- sort_header · table · pagination · accordion · stepper ·
588
- step_progress · step_list · timeline · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
594
+ sort_header · table · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
595
+ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
589
596
  command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
590
597
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
591
598
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
@@ -133,7 +133,7 @@ export function TplExtract() {
133
133
 
134
134
  {phase === "extracting" ? (
135
135
  <Card>
136
- <AgentRun title="Extracting line items" steps={steps} state={streaming ? "streaming" : "done"} />
136
+ <AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
137
137
  </Card>
138
138
  ) : null}
139
139
 
@@ -201,7 +201,7 @@ export function TplLookup() {
201
201
 
202
202
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
203
203
  {/* current run */}
204
- <AgentRun title="Classifying the goods" steps={steps} state={streaming ? "streaming" : "done"} />
204
+ <AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
205
205
 
206
206
  <View style={{ gap: 8 }}>
207
207
  <Text size="xs" color="muted" weight="medium">Nearest matches</Text>
@@ -11,7 +11,7 @@ import { Divider } from "@lotics/ui/divider";
11
11
  import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
12
12
  import { ProgressBar } from "@lotics/ui/progress_bar";
13
13
  import { ScanField } from "@lotics/ui/scan_field";
14
- import { StepList, type StepListItem } from "@lotics/ui/step_list";
14
+ import { Stepper, Step, type StepStatus } from "@lotics/ui/stepper";
15
15
  import { CompletionState } from "@lotics/ui/completion_state";
16
16
 
17
17
  // ─────────────────────────────────────────────────────────────────────────────
@@ -155,18 +155,6 @@ function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }
155
155
  // The right rail — the whole run at a glance: done · now · up next. Read-only;
156
156
  // the run is sequential, so position is shown, not chosen.
157
157
  function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
158
- const steps: StepListItem[] = lines.map((l, i) => ({
159
- id: l.id,
160
- status: l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "pending",
161
- title: l.bin,
162
- subtitle: l.item,
163
- trailing:
164
- l.status === "short" ? (
165
- <Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
166
- ) : (
167
- <Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
168
- ),
169
- }));
170
158
  return (
171
159
  <Card style={{ padding: 0 }}>
172
160
  <View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
@@ -174,7 +162,27 @@ function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: num
174
162
  </View>
175
163
  <Divider />
176
164
  <View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
177
- <StepList steps={steps} accessibilityLabel="Pick path" />
165
+ <Stepper orientation="vertical" accessibilityLabel="Pick path">
166
+ {lines.map((l, i) => {
167
+ const status: StepStatus =
168
+ l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "upcoming";
169
+ return (
170
+ <Step key={l.id} status={status}>
171
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 }}>
172
+ <View style={{ flex: 1, gap: 1 }}>
173
+ <Text size="sm">{l.bin}</Text>
174
+ <Text size="xs" color="muted" numberOfLines={1}>{l.item}</Text>
175
+ </View>
176
+ {l.status === "short" ? (
177
+ <Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
178
+ ) : (
179
+ <Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
180
+ )}
181
+ </View>
182
+ </Step>
183
+ );
184
+ })}
185
+ </Stepper>
178
186
  </View>
179
187
  </Card>
180
188
  );
@@ -238,7 +238,7 @@ export function TplRatedesk() {
238
238
  </View>
239
239
  ) : null}
240
240
 
241
- {phase === "reading" ? <AgentRun title="Reading carrier sheets" steps={runSteps} state="streaming" /> : null}
241
+ {phase === "reading" ? <AgentRun steps={runSteps} state="streaming" /> : null}
242
242
 
243
243
  {phase === "review" ? (
244
244
  <ChangeReview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -139,7 +139,6 @@
139
139
  "./table": "./src/table.tsx",
140
140
  "./detail_row": "./src/detail_row.tsx",
141
141
  "./scan_field": "./src/scan_field.tsx",
142
- "./step_list": "./src/step_list.tsx",
143
142
  "./completion_state": "./src/completion_state.tsx",
144
143
  "./remainder_meter": "./src/remainder_meter.tsx",
145
144
  "./allocation_row": "./src/allocation_row.tsx",
@@ -4,7 +4,7 @@ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { WaveAvatar } from "./wave_avatar";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
- import { AgentRun, type AgentRunStep } from "./agent_run";
7
+ import { AgentRun, resolveToolMeta, type AgentRunStep } from "./agent_run";
8
8
 
9
9
  export interface AgentProgressProps {
10
10
  steps: AgentRunStep[];
@@ -12,6 +12,11 @@ export interface AgentProgressProps {
12
12
  /** The collapsed-pill label. Defaults to the running step's label (or
13
13
  * "Working…" / "Done"). */
14
14
  label?: string;
15
+ /** The agent's streamed text — forwarded to the expanded `AgentRun`. */
16
+ text?: string;
17
+ /** Localize / override a tool step's label by raw tool name — applied to both
18
+ * the collapsed pill and the expanded feed. */
19
+ labelForTool?: (toolName: string) => string | undefined;
15
20
  defaultExpanded?: boolean;
16
21
  }
17
22
 
@@ -24,11 +29,16 @@ export interface AgentProgressProps {
24
29
  * label, so the surface stays calm until you ask to see the steps.
25
30
  */
26
31
  export function AgentProgress(props: AgentProgressProps) {
27
- const { steps, state = "streaming", label, defaultExpanded } = props;
32
+ const { steps, state = "streaming", label, text, labelForTool, defaultExpanded } = props;
28
33
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
29
34
 
30
35
  const running = steps.filter((s) => s.status === "running").at(-1);
31
- const compact = label ?? running?.label ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
36
+ const runningLabel = running
37
+ ? running.kind === "tool"
38
+ ? resolveToolMeta(running.label, labelForTool).label
39
+ : running.label
40
+ : undefined;
41
+ const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
32
42
  const streaming = state === "streaming";
33
43
 
34
44
  return (
@@ -36,7 +46,7 @@ export function AgentProgress(props: AgentProgressProps) {
36
46
  {expanded ? (
37
47
  <View style={styles.panel}>
38
48
  <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
39
- <AgentRun steps={steps} state={state} />
49
+ <AgentRun steps={steps} state={state} text={text} labelForTool={labelForTool} />
40
50
  </ScrollView>
41
51
  </View>
42
52
  ) : null}
package/src/agent_run.tsx CHANGED
@@ -1,174 +1,169 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid, tint } from "./colors";
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { Animated, StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
3
4
  import { Text } from "./text";
4
- import { Icon, type IconName } from "./icon";
5
- import { ActivityIndicator } from "./activity_indicator";
6
- import { AnimationFadeIn } from "./animation_fade_in";
5
+ import { type IconName } from "./icon";
6
+ import { Peek } from "./peek";
7
+ import { Stepper, Step, type StepStatus } from "./stepper";
7
8
 
8
9
  export type AgentStepStatus = "running" | "done" | "error";
9
10
 
10
11
  export interface AgentRunStep {
11
12
  id: string;
12
- /** The action line what the agent is doing ("Reading the image",
13
- * "Identifying the box style"). */
13
+ /** A `tool` step carries the RAW tool name (`update_records`) the feed maps
14
+ * it to a human label via the default map / `labelForTool`. A `step` carries
15
+ * an already-human label. */
14
16
  label: string;
15
- /** Streamed reasoning or result text under the label (muted). Grows as the
16
- * model streams; the host appends to it. */
17
+ /** A note under the label (a count, an input summary), muted. */
17
18
  detail?: string;
18
19
  status: AgentStepStatus;
19
- /** A `tool` step renders its label in a monospace chip — it called a tool
20
- * rather than reasoned. */
21
20
  kind?: "step" | "tool";
21
+ /** Optional content shown in a `Peek` popover when the step is pressed —
22
+ * glance into what the step did (the tool input, a result summary). */
23
+ peek?: ReactNode;
22
24
  }
23
25
 
24
26
  export interface AgentRunProps {
25
27
  steps: AgentRunStep[];
26
- /** Whole-run state. `streaming` keeps the feed alivethe running node spins
27
- * and the header shows a working indicator; `done`/`error` settle it.
28
- * Defaults to `streaming` while any step is running, else `done`. */
28
+ /** The agent's streamed reasoning / answer textshown as a narration block
29
+ * above the feed, with a live caret while streaming. */
30
+ text?: string;
31
+ /** Whole-run state. `streaming` keeps the caret blinking + the latest step
32
+ * pulsing; `done`/`error` settle it. Defaults to `streaming` while a step runs. */
29
33
  state?: "streaming" | "done" | "error";
30
- /** Header label the task the agent is performing ("Designing the dieline").
31
- * Omit for a bare step feed with no header band. */
32
- title?: string;
34
+ /** Localize / override a TOOL step's display label by its raw tool name
35
+ * (`update_records` "Đang cập nhật dữ liệu"). Return `undefined` to fall
36
+ * back to the built-in label. */
37
+ labelForTool?: (toolName: string) => string | undefined;
38
+ /** The always-last terminal step's label. Default "Done"; localize per app. */
39
+ doneLabel?: string;
33
40
  accessibilityLabel?: string;
34
41
  }
35
42
 
43
+ // ── Tool → display meta ───────────────────────────────────────────────────────
44
+ // The bounded set of platform tools an app agent can call. English by default
45
+ // (the kit is locale-neutral; apps localize via `labelForTool`). A tool the map
46
+ // doesn't know falls back to a prettified name + a neutral icon.
47
+ const TOOL_META: Record<string, { label: string; icon: IconName }> = {
48
+ query_records: { label: "Searching records", icon: "search" },
49
+ get_record: { label: "Reading a record", icon: "file-text" },
50
+ get_records: { label: "Reading records", icon: "file-text" },
51
+ create_records: { label: "Creating records", icon: "plus" },
52
+ update_records: { label: "Updating records", icon: "square-pen" },
53
+ delete_records: { label: "Removing records", icon: "trash" },
54
+ generate_pdf_from_template: { label: "Generating PDF", icon: "file-down" },
55
+ generate_excel_from_template: { label: "Generating spreadsheet", icon: "file-spreadsheet" },
56
+ generate_docx_from_template: { label: "Generating document", icon: "file-text" },
57
+ };
58
+
59
+ function prettifyToolName(name: string): string {
60
+ const s = name.replace(/_/g, " ").trim();
61
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : name;
62
+ }
63
+
64
+ /** Resolve a tool's display label + icon: a caller override wins on the label,
65
+ * then the built-in map, then a prettified fallback. Exported so other agent
66
+ * surfaces (e.g. `AgentProgress`) render the same labels. */
67
+ export function resolveToolMeta(
68
+ toolName: string,
69
+ labelForTool?: (toolName: string) => string | undefined,
70
+ ): { label: string; icon: IconName } {
71
+ const def =
72
+ TOOL_META[toolName] ??
73
+ (toolName.startsWith("generate_") && toolName.endsWith("_from_template")
74
+ ? { label: "Generating document", icon: "file-text" as IconName }
75
+ : undefined);
76
+ return {
77
+ label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
78
+ icon: def?.icon ?? "list",
79
+ };
80
+ }
81
+
36
82
  /**
37
- * A live feed of an AI agent's work — the steps it takes (reasoning, tool
38
- * calls, results) revealed as they happen, on a connecting spine. The
39
- * transparent-work surface, NOT a spinner. Shares the `Timeline` node anatomy: a
40
- * tinted disc behind a full-colour icon, fading in as each step arrives. A
41
- * running step spins (blue); a finished step settles to an emerald check; a
42
- * failure to a red alert. Pair with `Composer` (the command that starts a
43
- * run) and `ReviewCard` (the result to review). Unlike `StepList` (a known,
44
- * guided run of fixed steps) the steps here arrive unknown-ahead.
83
+ * A live feed of an AI agent's work — the prose it streams + the tools it calls,
84
+ * on a vertical `Stepper`: one neutral dot per call (the latest pulsing while the
85
+ * run is live, a check once done) with a plain-text label, no chip/icon, ALWAYS
86
+ * ending on a "Done" terminal step. A step can carry `peek` content to glance
87
+ * into via a popover. Tool names map to labels via the built-in default + a
88
+ * `labelForTool` override. Pair with `Composer` + `ReviewCard`.
45
89
  */
46
90
  export function AgentRun(props: AgentRunProps) {
47
- const { steps, title, accessibilityLabel } = props;
91
+ const { steps, text, labelForTool, doneLabel = "Done", accessibilityLabel } = props;
48
92
  const anyRunning = steps.some((s) => s.status === "running");
49
93
  const state = props.state ?? (anyRunning ? "streaming" : "done");
50
94
  const streaming = state === "streaming";
95
+ const last = steps.length - 1;
51
96
 
52
97
  return (
53
- <View accessibilityLabel={accessibilityLabel} style={{ gap: title ? 12 : 0 }}>
54
- {title ? (
55
- <View style={styles.header}>
56
- <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
57
- {title}
58
- </Text>
59
- <HeaderStatus state={state} streaming={streaming} />
60
- </View>
98
+ <View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
99
+ {text ? (
100
+ <Text size="sm" style={styles.narration}>
101
+ {text}
102
+ {streaming ? <Caret /> : null}
103
+ </Text>
61
104
  ) : null}
62
105
 
63
- <View>
106
+ <Stepper orientation="vertical" live={streaming}>
64
107
  {steps.map((s, i) => {
65
- const last = i === steps.length - 1;
66
- return (
67
- <View key={s.id} style={styles.item}>
68
- <View style={styles.spineCol}>
69
- <AnimationFadeIn key={`${s.id}-${s.status}`}>
70
- <StepNode status={s.status} />
71
- </AnimationFadeIn>
72
- {!last ? <View style={styles.spine} /> : null}
73
- </View>
74
- <View style={[styles.contentCol, !last ? styles.contentGap : null]}>
75
- {s.kind === "tool" ? (
76
- <View style={styles.toolChip}>
77
- <Text size="xs" color="muted" numberOfLines={1}>
78
- {s.label}
79
- </Text>
80
- </View>
81
- ) : (
82
- <Text
83
- size="sm"
84
- weight={s.status === "running" ? "medium" : "regular"}
85
- color={s.status === "done" ? "muted" : s.status === "error" ? "danger" : "default"}
86
- >
87
- {s.label}
88
- </Text>
89
- )}
90
- {s.detail ? (
91
- <Text size="xs" color="muted" style={{ marginTop: 2 }}>
92
- {s.detail}
93
- </Text>
94
- ) : null}
95
- </View>
108
+ // Tools the SDK feeds arrive already "done"; show the most recent as
109
+ // the pulsing current while the run is still live.
110
+ const status: StepStatus =
111
+ s.status === "error"
112
+ ? "warning"
113
+ : s.status === "running" || (streaming && i === last)
114
+ ? "current"
115
+ : "done";
116
+ const label = s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
117
+ const body = (
118
+ <View style={{ gap: 1 }}>
119
+ <Text size="sm">{label}</Text>
120
+ {s.detail ? (
121
+ <Text size="xs" color={s.status === "error" ? "danger" : "muted"}>
122
+ {s.detail}
123
+ </Text>
124
+ ) : null}
96
125
  </View>
97
126
  );
127
+ return (
128
+ <Step key={s.id} status={status}>
129
+ {s.peek ? (
130
+ <Peek accessibilityLabel={label} content={s.peek}>
131
+ {body}
132
+ </Peek>
133
+ ) : (
134
+ body
135
+ )}
136
+ </Step>
137
+ );
98
138
  })}
99
- </View>
139
+ {state === "done" ? (
140
+ <Step key="__done" status="complete">
141
+ <Text size="sm" weight="medium">
142
+ {doneLabel}
143
+ </Text>
144
+ </Step>
145
+ ) : null}
146
+ </Stepper>
100
147
  </View>
101
148
  );
102
149
  }
103
150
 
104
- const NODE_STATUS: Record<"done" | "error", { color: string; icon: IconName }> = {
105
- done: { color: solid("emerald"), icon: "check" },
106
- error: { color: solid("red"), icon: "circle-alert" },
107
- };
108
-
109
- function StepNode({ status }: { status: AgentStepStatus }) {
110
- if (status === "running") {
111
- return (
112
- <View style={[styles.node, { backgroundColor: tint("blue", 0.12) }]}>
113
- <ActivityIndicator size={12} color={solid("blue")} />
114
- </View>
151
+ // A blinking caret that trails the streamed text the signature of live writing.
152
+ function Caret() {
153
+ const opacity = useRef(new Animated.Value(1)).current;
154
+ useEffect(() => {
155
+ const loop = Animated.loop(
156
+ Animated.sequence([
157
+ Animated.timing(opacity, { toValue: 0, duration: 480, useNativeDriver: false }),
158
+ Animated.timing(opacity, { toValue: 1, duration: 480, useNativeDriver: false }),
159
+ ]),
115
160
  );
116
- }
117
- const s = NODE_STATUS[status];
118
- return (
119
- <View style={[styles.node, { backgroundColor: tint(status === "done" ? "emerald" : "red", 0.12) }]}>
120
- <Icon name={s.icon} size={13} color={s.color} />
121
- </View>
122
- );
123
- }
124
-
125
- function HeaderStatus({ state, streaming }: { state: "streaming" | "done" | "error"; streaming: boolean }) {
126
- if (streaming) {
127
- return (
128
- <View style={styles.headerStatus}>
129
- <ActivityIndicator size={12} color={solid("blue")} />
130
- <Text size="xs" weight="medium" style={{ color: solid("blue") }}>
131
- Working
132
- </Text>
133
- </View>
134
- );
135
- }
136
- if (state === "error") {
137
- return (
138
- <View style={styles.headerStatus}>
139
- <Icon name="circle-alert" size={13} color={solid("red")} />
140
- <Text size="xs" weight="medium" color="danger">
141
- Stopped
142
- </Text>
143
- </View>
144
- );
145
- }
146
- return (
147
- <View style={styles.headerStatus}>
148
- <Icon name="circle-check" size={13} color={solid("emerald")} />
149
- <Text size="xs" weight="medium" style={{ color: solid("emerald") }}>
150
- Done
151
- </Text>
152
- </View>
153
- );
161
+ loop.start();
162
+ return () => loop.stop();
163
+ }, [opacity]);
164
+ return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
154
165
  }
155
166
 
156
- const NODE = 22;
157
-
158
167
  const styles = StyleSheet.create({
159
- header: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 20 },
160
- headerStatus: { flexDirection: "row", alignItems: "center", gap: 5 },
161
- item: { flexDirection: "row", gap: 12 },
162
- spineCol: { width: NODE, alignItems: "center", paddingTop: 1 },
163
- spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
164
- contentCol: { flex: 1, paddingTop: 2 },
165
- contentGap: { paddingBottom: 14 },
166
- node: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
167
- toolChip: {
168
- alignSelf: "flex-start",
169
- paddingHorizontal: 8,
170
- paddingVertical: 3,
171
- borderRadius: 6,
172
- backgroundColor: colors.zinc[100],
173
- },
168
+ narration: { lineHeight: 21, color: colors.zinc[700] },
174
169
  });
@@ -4,19 +4,26 @@ import { Animated, StyleProp, ViewStyle } from "react-native";
4
4
  interface AnimationFadeInProps {
5
5
  children: React.ReactNode;
6
6
  style?: StyleProp<ViewStyle>;
7
+ /** Rise this many px while fading in — the "appearing into place" gesture.
8
+ * Default 0 (fade only). */
9
+ translateY?: number;
7
10
  }
8
11
 
9
12
  export function AnimationFadeIn(props: AnimationFadeInProps) {
10
- const { children, style } = props;
11
- const opacity = useRef(new Animated.Value(0)).current;
13
+ const { children, style, translateY = 0 } = props;
14
+ const progress = useRef(new Animated.Value(0)).current;
12
15
 
13
16
  useEffect(() => {
14
- Animated.spring(opacity, {
17
+ Animated.spring(progress, {
15
18
  toValue: 1,
16
19
  bounciness: 0,
17
20
  useNativeDriver: true,
18
21
  }).start();
19
- }, [opacity]);
22
+ }, [progress]);
20
23
 
21
- return <Animated.View style={[{ opacity }, style]}>{children}</Animated.View>;
24
+ const transform = translateY
25
+ ? [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [translateY, 0] }) }]
26
+ : undefined;
27
+
28
+ return <Animated.View style={[{ opacity: progress, transform }, style]}>{children}</Animated.View>;
22
29
  }
@@ -29,6 +29,17 @@ describe("shareOrDownloadFiles", () => {
29
29
  expect(shared.title).toBe("Ảnh");
30
30
  });
31
31
 
32
+ it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
33
+ // Type the init param so mock.calls[0][1] is the RequestInit we assert on.
34
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
35
+ vi.stubGlobal("fetch", fetchMock);
36
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
37
+
38
+ await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
39
+
40
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
41
+ });
42
+
32
43
  it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
33
44
  vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
34
45
  const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
package/src/stepper.tsx CHANGED
@@ -1,88 +1,229 @@
1
- import { View } from "react-native";
2
- import { colors } from "./colors";
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ createContext,
5
+ isValidElement,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { Animated, StyleSheet, View } from "react-native";
13
+ import { colors, solid } from "./colors";
3
14
  import { Icon } from "./icon";
4
15
  import { Text } from "./text";
16
+ import { PressableHighlight } from "./pressable_highlight";
17
+ import { AnimationFadeIn } from "./animation_fade_in";
5
18
 
6
- export type StepStatus = "done" | "current" | "upcoming";
19
+ // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
20
+ // where we are (ring + white centre, pulses when live); `done` = passed (filled);
21
+ // `complete` = the terminal "finished" marker (filled + check); `warning` = an
22
+ // issue at a reached step.
23
+ export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
24
+ export type StepOrientation = "horizontal" | "vertical";
25
+
26
+ const reached = (s: StepStatus) => s !== "upcoming";
27
+
28
+ interface StepperConfig {
29
+ orientation: StepOrientation;
30
+ color: string;
31
+ /** Pulse the current node — signals an actively-running sequence (an agent
32
+ * run streaming its steps). Off for a static wizard/checklist. */
33
+ live: boolean;
34
+ }
35
+ const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false });
7
36
 
8
37
  export interface StepperProps {
9
- /** Ordered step labels, left right. */
10
- steps: string[];
11
- /** Index of the in-progress step; lower indices render complete. */
12
- current: number;
13
- /** Accent for the completed track, current ring, and current label.
14
- * Defaults to the neutral ink — pass a brand color to theme it. */
38
+ /** Compound form: `<Stepper><Step status>…</Step></Stepper>` swappable content. */
39
+ children?: ReactNode;
40
+ /** Data form: ordered labels; `current` derives each step's status. Ignored when `children` is set. */
41
+ steps?: string[];
42
+ current?: number;
43
+ orientation?: StepOrientation;
44
+ live?: boolean;
45
+ /** Accent ink for reached nodes + the connecting track. Defaults to neutral ink. */
15
46
  color?: string;
16
47
  accessibilityLabel?: string;
17
48
  }
18
49
 
19
- function statusOf(index: number, current: number): StepStatus {
20
- if (index < current) return "done";
21
- if (index === current) return "current";
22
- return "upcoming";
50
+ interface StepPositional {
51
+ _last?: boolean;
52
+ _leftFilled?: boolean;
53
+ _rightFilled?: boolean;
54
+ }
55
+
56
+ export interface StepProps extends StepPositional {
57
+ status: StepStatus;
58
+ children: ReactNode;
59
+ /** Make the step navigable; the active one holds a wash so a panel can sit beside it (vertical). */
60
+ onPress?: () => void;
61
+ active?: boolean;
62
+ accessibilityLabel?: string;
23
63
  }
24
64
 
25
65
  /**
26
- * Full-width wizard/milestone headera row of labeled milestones with
27
- * completed, current, and upcoming states and a connecting track. Every
28
- * label is visible; use it where the journey itself is the headline (a
29
- * record detail, a checkout). At card/list density use `StepProgress`
30
- * (segments + built-in caption) instead. For a vertical event log use
31
- * `Timeline`.
66
+ * Progress through an ordered sequence done · current · upcoming on a
67
+ * connecting track (horizontal) or spine (vertical). The node encodes STATUS,
68
+ * not identity: a filled dot once reached, a ring with a white centre at the
69
+ * current step (pulsing when `live`), a faint ring for what's ahead, a check for
70
+ * the terminal `complete`. Drive it the compound way (`<Step status>` children —
71
+ * the label is yours to compose) or the data way (`steps` + `current`). For a
72
+ * heterogeneous event LOG with per-row icons + expandable details use `Timeline`;
73
+ * for a compact stage bar use `StepProgress`.
32
74
  */
33
75
  export function Stepper(props: StepperProps) {
34
- const { steps, current, color = colors.zinc[900], accessibilityLabel } = props;
35
- const last = steps.length - 1;
36
- const safe = Math.max(0, Math.min(current, last));
37
- const a11y = accessibilityLabel ?? `Step ${safe + 1} of ${steps.length}: ${steps[safe] ?? ""}`;
76
+ const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
77
+
78
+ const content: ReactNode =
79
+ children ??
80
+ (steps ?? []).map((label, i) => {
81
+ const status: StepStatus = current == null ? "upcoming" : i < current ? "done" : i === current ? "current" : "upcoming";
82
+ const isCurrent = status === "current";
83
+ return (
84
+ <Step key={label} status={status}>
85
+ <Text size="xs" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "semibold" : "regular"} style={isCurrent ? { color } : undefined}>
86
+ {label}
87
+ </Text>
88
+ </Step>
89
+ );
90
+ });
91
+
92
+ const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
93
+ const statuses = items.map((c) => c.props.status);
94
+ const last = items.length - 1;
95
+ const a11y = accessibilityLabel ?? "Progress";
96
+
97
+ const positioned = items.map((child, i) =>
98
+ cloneElement(child, {
99
+ _last: i === last,
100
+ _leftFilled: i > 0 && reached(statuses[i - 1]),
101
+ _rightFilled: i < last && reached(statuses[i]),
102
+ }),
103
+ );
38
104
 
39
105
  return (
40
- <View
41
- accessibilityRole="progressbar"
42
- accessibilityLabel={a11y}
43
- style={{ flexDirection: "row" }}
44
- >
45
- {steps.map((label, i) => {
46
- const status = statusOf(i, current);
47
- const leftFilled = i <= current && i > 0;
48
- const rightFilled = i < current && i < last;
49
- const isCurrent = status === "current";
50
- return (
51
- <View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
52
- <View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
53
- <View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
54
- <StepDot status={status} color={color} />
55
- <View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
56
- </View>
57
- <Text
58
- size="xs"
59
- color={isCurrent ? "default" : "muted"}
60
- weight={isCurrent ? "semibold" : "regular"}
61
- style={isCurrent ? { color } : undefined}
62
- >
63
- {label}
64
- </Text>
65
- </View>
66
- );
67
- })}
68
- </View>
106
+ <StepperContext.Provider value={{ orientation, color, live }}>
107
+ <View accessibilityRole="progressbar" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
108
+ {positioned}
109
+ </View>
110
+ </StepperContext.Provider>
69
111
  );
70
112
  }
71
113
 
72
- function StepDot({ status, color }: { status: StepStatus; color: string }) {
73
- if (status === "done") {
74
- return <Icon name="circle-check" size={16} color={color} />;
114
+ export function Step(props: StepProps) {
115
+ const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
116
+ const { orientation, color, live } = useContext(StepperContext);
117
+
118
+ if (orientation === "horizontal") {
119
+ return (
120
+ <View style={styles.hStep}>
121
+ <View style={styles.hTrackRow}>
122
+ <View style={[styles.hTrack, { backgroundColor: _leftFilled ? color : colors.zinc[200] }]} />
123
+ <Marker status={status} color={color} live={live} />
124
+ <View style={[styles.hTrack, { backgroundColor: _rightFilled ? color : colors.zinc[200] }]} />
125
+ </View>
126
+ <View style={styles.hLabel}>{children}</View>
127
+ </View>
128
+ );
75
129
  }
130
+
131
+ const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
132
+ // Each step rises + fades into place on mount — so a streamed feed reads as
133
+ // steps APPEARING, not popping in. Animates once (on mount); a status change
134
+ // (current → done) re-renders without re-animating.
76
135
  return (
77
- <View
78
- style={{
79
- width: 16,
80
- height: 16,
81
- borderRadius: 8,
82
- backgroundColor: colors.white,
83
- borderWidth: status === "current" ? 3 : 1.5,
84
- borderColor: status === "current" ? color : colors.zinc[300],
85
- }}
86
- />
136
+ <AnimationFadeIn translateY={6}>
137
+ <View style={styles.vItem}>
138
+ <View style={styles.vSpineCol}>
139
+ <Marker status={status} color={color} live={live} />
140
+ {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
141
+ </View>
142
+ <View style={[styles.vContent, !_last ? styles.vGap : null]}>
143
+ {onPress ? (
144
+ <PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
145
+ {body}
146
+ </PressableHighlight>
147
+ ) : (
148
+ body
149
+ )}
150
+ </View>
151
+ </View>
152
+ </AnimationFadeIn>
87
153
  );
88
154
  }
155
+
156
+ // The node — status drives fill, never size. Monochrome: reached fills with ink,
157
+ // the current step is a ring around white (pulsing when live), what's ahead is a
158
+ // faint grey ring, the terminal is a checked fill.
159
+ function Marker({ status, color, live }: { status: StepStatus; color: string; live: boolean }) {
160
+ if (status === "done") {
161
+ // A passed step: filled in the accent ink with a white check.
162
+ return (
163
+ <View style={[styles.disc, { backgroundColor: color }]}>
164
+ <Icon name="check" size={10} color={colors.white} />
165
+ </View>
166
+ );
167
+ }
168
+ if (status === "complete") {
169
+ // The terminal "Done" — a blackish ring with a white centre and a black check.
170
+ return (
171
+ <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2, borderColor: colors.zinc[900] }]}>
172
+ <Icon name="check" size={10} color={colors.zinc[900]} />
173
+ </View>
174
+ );
175
+ }
176
+ if (status === "warning") {
177
+ return (
178
+ <View style={[styles.disc, { backgroundColor: solid("amber") }]}>
179
+ <View style={styles.shortBar} />
180
+ </View>
181
+ );
182
+ }
183
+ if (status === "current") {
184
+ return (
185
+ <View style={styles.discWrap}>
186
+ {live ? <Pulse color={color} /> : null}
187
+ <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2.5, borderColor: color }]} />
188
+ </View>
189
+ );
190
+ }
191
+ return <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 1.5, borderColor: colors.zinc[300] }]} />;
192
+ }
193
+
194
+ // An expanding, fading halo behind the current node — the "live" pulse.
195
+ function Pulse({ color }: { color: string }) {
196
+ const v = useRef(new Animated.Value(0)).current;
197
+ useEffect(() => {
198
+ const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: 1500, useNativeDriver: false }));
199
+ loop.start();
200
+ return () => loop.stop();
201
+ }, [v]);
202
+ const transform = [{ scale: v.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) }];
203
+ const opacity = v.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] });
204
+ return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
205
+ }
206
+
207
+ const NODE = 18;
208
+
209
+ const styles = StyleSheet.create({
210
+ hRow: { flexDirection: "row" },
211
+ hStep: { flex: 1, alignItems: "center", gap: 8 },
212
+ hTrackRow: { flexDirection: "row", alignItems: "center", width: "100%" },
213
+ hTrack: { flex: 1, height: 2 },
214
+ hLabel: { alignItems: "center" },
215
+
216
+ vItem: { flexDirection: "row", gap: 12 },
217
+ vSpineCol: { width: NODE, alignItems: "center", paddingTop: 3 },
218
+ vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
219
+ vContent: { flex: 1 },
220
+ vGap: { paddingBottom: 12 },
221
+ vPress: { borderRadius: 8, marginHorizontal: -10 },
222
+ vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 2 },
223
+ vActive: { backgroundColor: colors.zinc[100] },
224
+
225
+ discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
226
+ disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
227
+ pulse: { position: "absolute", width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2, pointerEvents: "none" },
228
+ shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.white },
229
+ });
package/src/step_list.tsx DELETED
@@ -1,128 +0,0 @@
1
- import { ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
3
- import { colors, solid } from "./colors";
4
- import { Text } from "./text";
5
- import { Icon } from "./icon";
6
- import { PressableHighlight } from "./pressable_highlight";
7
-
8
- export type StepStatus = "pending" | "current" | "done" | "warning";
9
-
10
- export interface StepListItem {
11
- id: string;
12
- status: StepStatus;
13
- /** Primary line — a stage, a code, a name. */
14
- title: string;
15
- /** Secondary muted line. */
16
- subtitle?: string;
17
- /** Right-aligned node — a count, a `Badge`, a time. */
18
- trailing?: ReactNode;
19
- }
20
-
21
- export interface StepListProps {
22
- steps: StepListItem[];
23
- /** When set, steps become pressable to navigate; the matching step is held
24
- * highlighted so its panel can be shown elsewhere. */
25
- selectedId?: string;
26
- onStepPress?: (id: string) => void;
27
- accessibilityLabel?: string;
28
- }
29
-
30
- /**
31
- * A vertical step sequence on a connecting spine — done · now · up next — for a
32
- * guided run, a staged process, a checklist. Every status is the SAME 20px node
33
- * (filled once reached, hollow while pending), so the spine reads as one line no
34
- * matter which statuses appear or in what order. Unlike `Stepper` (horizontal) or
35
- * `StepProgress` (a bar), and unlike `Timeline` (past events only), it models
36
- * future/pending steps. Pass `onStepPress` to make the steps NAVIGABLE — the
37
- * selected step is held highlighted so the host can show its panel beside the list.
38
- */
39
- export function StepList(props: StepListProps) {
40
- const { steps, selectedId, onStepPress, accessibilityLabel } = props;
41
- return (
42
- <View accessibilityLabel={accessibilityLabel}>
43
- {steps.map((s, i) => {
44
- // The focused step: the navigated one when navigable, else "now".
45
- const active = selectedId != null ? s.id === selectedId : s.status === "current";
46
- const past = s.status === "done" || s.status === "warning";
47
- const content = (
48
- <View style={styles.contentRow}>
49
- <View style={{ flex: 1, gap: 1 }}>
50
- <Text size="sm" weight={active ? "medium" : "regular"} color={past && !active ? "muted" : "default"}>
51
- {s.title}
52
- </Text>
53
- {s.subtitle ? (
54
- <Text size="xs" color="muted" numberOfLines={1}>{s.subtitle}</Text>
55
- ) : null}
56
- </View>
57
- {s.trailing}
58
- </View>
59
- );
60
- return (
61
- <View key={s.id} style={styles.item}>
62
- <View style={styles.spineCol}>
63
- <Marker status={s.status} />
64
- {i < steps.length - 1 ? <View style={styles.spine} /> : null}
65
- </View>
66
- <View style={[styles.contentCol, i < steps.length - 1 ? styles.contentGap : null]}>
67
- {onStepPress ? (
68
- <PressableHighlight onPress={() => onStepPress(s.id)} style={[styles.rowBox, active ? styles.active : null]}>
69
- {content}
70
- </PressableHighlight>
71
- ) : (
72
- <View style={[styles.rowBox, active ? styles.active : null]}>{content}</View>
73
- )}
74
- </View>
75
- </View>
76
- );
77
- })}
78
- </View>
79
- );
80
- }
81
-
82
- /** One uniform 20px node; status drives fill, not size or shape. */
83
- function Marker({ status }: { status: StepStatus }) {
84
- if (status === "pending") {
85
- return <View style={[styles.disc, styles.discPending]} />;
86
- }
87
- if (status === "current") {
88
- return (
89
- <View style={[styles.disc, { backgroundColor: solid("blue") }]}>
90
- <View style={styles.currentDot} />
91
- </View>
92
- );
93
- }
94
- if (status === "warning") {
95
- return (
96
- <View style={[styles.disc, { backgroundColor: solid("amber") }]}>
97
- <View style={styles.shortBar} />
98
- </View>
99
- );
100
- }
101
- return (
102
- <View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
103
- <Icon name="check" size={12} color={colors.background} />
104
- </View>
105
- );
106
- }
107
-
108
- const NODE = 20;
109
-
110
- const styles = StyleSheet.create({
111
- item: { flexDirection: "row", gap: 12 },
112
- // paddingTop pairs with rowBox.paddingVertical so the node centres on the
113
- // FIRST line of content, not on a multi-line block.
114
- spineCol: { width: NODE, alignItems: "center", paddingTop: 4 },
115
- disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
116
- discPending: { backgroundColor: colors.background, borderWidth: 1.5, borderColor: colors.zinc[300] },
117
- currentDot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.background },
118
- shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.background },
119
- spine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 4 },
120
- contentCol: { flex: 1 },
121
- // The inter-step gap — on every step but the last, so the list ends flush and
122
- // a container's own padding isn't doubled at the bottom.
123
- contentGap: { paddingBottom: 12 },
124
- contentRow: { flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 },
125
- // press + plain share one box so the active wash looks identical either way.
126
- rowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginHorizontal: -10, flexDirection: "row", alignItems: "flex-start" },
127
- active: { backgroundColor: colors.zinc[100] },
128
- });