@lotics/ui 5.6.1 → 5.8.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.
Files changed (69) hide show
  1. package/AGENTS.md +44 -27
  2. package/examples/tpl_approvals.tsx +10 -10
  3. package/examples/tpl_crm_desk.tsx +3 -3
  4. package/examples/tpl_dieline.tsx +7 -6
  5. package/examples/tpl_directory.tsx +3 -3
  6. package/examples/tpl_dossier.tsx +7 -7
  7. package/examples/tpl_draft.tsx +4 -4
  8. package/examples/tpl_extract.tsx +7 -7
  9. package/examples/tpl_inventory.tsx +3 -3
  10. package/examples/tpl_lookup.tsx +4 -3
  11. package/examples/tpl_match.tsx +1 -1
  12. package/examples/tpl_ratedesk.tsx +11 -11
  13. package/examples/tpl_report.tsx +3 -3
  14. package/examples/tpl_stock.tsx +3 -3
  15. package/examples/tpl_triage.tsx +1 -1
  16. package/package.json +12 -3
  17. package/src/agent_progress.tsx +14 -12
  18. package/src/agent_run.tsx +248 -86
  19. package/src/back_button.tsx +3 -2
  20. package/src/badge.tsx +2 -1
  21. package/src/button.tsx +11 -32
  22. package/src/button_colors.ts +23 -0
  23. package/src/calendar/calendar_view.tsx +2 -2
  24. package/src/card_select_item.tsx +2 -1
  25. package/src/change_review.tsx +5 -6
  26. package/src/{pill_button.tsx → chip.tsx} +7 -7
  27. package/src/chip_group.tsx +2 -2
  28. package/src/column_filter.tsx +4 -4
  29. package/src/combobox.tsx +2 -9
  30. package/src/composer.tsx +4 -3
  31. package/src/control_surface.ts +15 -15
  32. package/src/date_field.tsx +2 -1
  33. package/src/date_filter.tsx +2 -1
  34. package/src/date_range_filter_field.tsx +2 -1
  35. package/src/dialog.tsx +2 -2
  36. package/src/discrepancy.tsx +1 -1
  37. package/src/drawer.tsx +1 -2
  38. package/src/{filter_pill.tsx → filter_chip.tsx} +8 -8
  39. package/src/finding.tsx +1 -1
  40. package/src/gantt/gantt_view.tsx +2 -2
  41. package/src/icon_button.tsx +38 -14
  42. package/src/inline_edit.tsx +2 -2
  43. package/src/link_button.tsx +2 -1
  44. package/src/markdown.css +209 -0
  45. package/src/markdown.tsx +17 -0
  46. package/src/markdown.web.tsx +69 -0
  47. package/src/menu_button.tsx +2 -1
  48. package/src/menu_list_item.tsx +2 -1
  49. package/src/number_input.tsx +2 -1
  50. package/src/picker.tsx +3 -3
  51. package/src/popover_header.tsx +2 -2
  52. package/src/primitives_purity.test.ts +13 -7
  53. package/src/radio_picker.tsx +2 -1
  54. package/src/range_slider.tsx +1 -1
  55. package/src/review_card.tsx +2 -2
  56. package/src/scan_field.tsx +2 -1
  57. package/src/scored_option.tsx +0 -1
  58. package/src/search_input.tsx +3 -2
  59. package/src/segmented_control.tsx +2 -1
  60. package/src/status_badge.tsx +2 -1
  61. package/src/stepper.tsx +3 -2
  62. package/src/switch_button.tsx +2 -1
  63. package/src/switcher.tsx +3 -2
  64. package/src/tabs.tsx +2 -1
  65. package/src/tag_input.tsx +2 -1
  66. package/src/text_input_field.tsx +2 -1
  67. package/src/time_field.tsx +2 -1
  68. package/src/time_picker.tsx +2 -1
  69. package/src/triage_row.tsx +3 -3
@@ -12,7 +12,7 @@ import { Drawer } from "@lotics/ui/drawer";
12
12
  import { EmptyState } from "@lotics/ui/empty_state";
13
13
  import { KPIStrip } from "@lotics/ui/kpi_strip";
14
14
  import { Pagination } from "@lotics/ui/pagination";
15
- import { PillButton } from "@lotics/ui/pill_button";
15
+ import { Chip } from "@lotics/ui/chip";
16
16
  import { PressableRow } from "@lotics/ui/pressable_row";
17
17
  import { SearchInput } from "@lotics/ui/search_input";
18
18
  import { formatMoney } from "@lotics/ui/format_money";
@@ -266,9 +266,9 @@ export function TplStock() {
266
266
  </CardHeaderTitle>
267
267
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
268
268
  {chips.map((c) => (
269
- <PillButton key={c.key} onDismiss={c.clear} dismissTooltip="Clear filter">
269
+ <Chip key={c.key} onDismiss={c.clear} dismissTooltip="Clear filter">
270
270
  <Text size="xs" weight="medium" color="muted">{c.label}</Text>
271
- </PillButton>
271
+ </Chip>
272
272
  ))}
273
273
  <View style={{ width: 240 }}>
274
274
  <SearchInput
@@ -62,7 +62,7 @@ export function TplTriage() {
62
62
  <Text size="sm" color="muted">The agent classifies and routes each item — accept the call, override, or dismiss</Text>
63
63
  </View>
64
64
  {tab === "open" && highOpen > 0 ? (
65
- <Button title={`Accept ${highOpen} high-confidence`} color="secondary" shape="rounded" onPress={acceptAllHigh} />
65
+ <Button title={`Accept ${highOpen} high-confidence`} color="secondary" onPress={acceptAllHigh} />
66
66
  ) : null}
67
67
  </View>
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.6.1",
3
+ "version": "5.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -63,6 +63,11 @@
63
63
  "./activity_indicator": "./src/activity_indicator.tsx",
64
64
  "./agent_run": "./src/agent_run.tsx",
65
65
  "./agent_progress": "./src/agent_progress.tsx",
66
+ "./markdown": {
67
+ "react-native": "./src/markdown.tsx",
68
+ "default": "./src/markdown.web.tsx"
69
+ },
70
+ "./markdown.css": "./src/markdown.css",
66
71
  "./confidence": "./src/confidence.tsx",
67
72
  "./review_card": "./src/review_card.tsx",
68
73
  "./change_review": "./src/change_review.tsx",
@@ -128,8 +133,8 @@
128
133
  "./radio_picker": "./src/radio_picker.tsx",
129
134
  "./form_text_input": "./src/form_text_input.tsx",
130
135
  "./form_switch": "./src/form_switch.tsx",
131
- "./pill_button": "./src/pill_button.tsx",
132
- "./filter_pill": "./src/filter_pill.tsx",
136
+ "./chip": "./src/chip.tsx",
137
+ "./filter_chip": "./src/filter_chip.tsx",
133
138
  "./range_slider": "./src/range_slider.tsx",
134
139
  "./counter": "./src/counter.tsx",
135
140
  "./link": "./src/link.tsx",
@@ -217,6 +222,10 @@
217
222
  "directory": "packages/ui"
218
223
  },
219
224
  "license": "MIT",
225
+ "dependencies": {
226
+ "react-markdown": "^10.0.0",
227
+ "remark-gfm": "^4.0.0"
228
+ },
220
229
  "peerDependencies": {
221
230
  "@lotics/docx": "^0.1.0",
222
231
  "@lotics/xlsx": "^0.1.0",
@@ -4,19 +4,20 @@ 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, resolveToolMeta, type AgentRunStep } from "./agent_run";
7
+ import { AgentRun, resolveToolMeta, type AgentRunItem } from "./agent_run";
8
8
 
9
9
  export interface AgentProgressProps {
10
- steps: AgentRunStep[];
10
+ /** The ordered transcript — same shape `AgentRun` takes. */
11
+ items: AgentRunItem[];
11
12
  state?: "streaming" | "done" | "error";
12
13
  /** The collapsed-pill label. Defaults to the running step's label (or
13
14
  * "Working…" / "Done"). */
14
15
  label?: string;
15
- /** The agent's streamed text — forwarded to the expanded `AgentRun`. */
16
- text?: string;
17
16
  /** Localize / override a tool step's label by raw tool name — applied to both
18
17
  * the collapsed pill and the expanded feed. */
19
18
  labelForTool?: (toolName: string) => string | undefined;
19
+ /** Localized "{n} steps" suffix, forwarded to the expanded feed. */
20
+ stepsLabel?: (n: number) => string;
20
21
  defaultExpanded?: boolean;
21
22
  }
22
23
 
@@ -29,15 +30,16 @@ export interface AgentProgressProps {
29
30
  * label, so the surface stays calm until you ask to see the steps.
30
31
  */
31
32
  export function AgentProgress(props: AgentProgressProps) {
32
- const { steps, state = "streaming", label, text, labelForTool, defaultExpanded } = props;
33
+ const { items, state = "streaming", label, labelForTool, stepsLabel, defaultExpanded } = props;
33
34
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
34
35
 
35
- const running = steps.filter((s) => s.status === "running").at(-1);
36
- const runningLabel = running
37
- ? running.kind === "tool"
38
- ? resolveToolMeta(running.label, labelForTool).label
39
- : running.label
40
- : undefined;
36
+ const running = [...items].reverse().find((it) => it.type === "step" && it.status === "running");
37
+ const runningLabel =
38
+ running && running.type === "step"
39
+ ? running.kind === "tool"
40
+ ? resolveToolMeta(running.label, labelForTool).label
41
+ : running.label
42
+ : undefined;
41
43
  const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
42
44
  const streaming = state === "streaming";
43
45
 
@@ -46,7 +48,7 @@ export function AgentProgress(props: AgentProgressProps) {
46
48
  {expanded ? (
47
49
  <View style={styles.panel}>
48
50
  <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
49
- <AgentRun steps={steps} state={state} text={text} labelForTool={labelForTool} />
51
+ <AgentRun items={items} state={state} labelForTool={labelForTool} stepsLabel={stepsLabel} />
50
52
  </ScrollView>
51
53
  </View>
52
54
  ) : null}
package/src/agent_run.tsx CHANGED
@@ -1,10 +1,14 @@
1
- import { useEffect, useRef, type ReactNode } from "react";
2
- import { Animated, StyleSheet, View } from "react-native";
3
- import { colors, solid } from "./colors";
1
+ import { useState, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
- import { type IconName } from "./icon";
5
+ import { Icon, type IconName } from "./icon";
6
+ import { Markdown } from "./markdown";
6
7
  import { Peek } from "./peek";
7
- import { Stepper, Step, type StepStatus } from "./stepper";
8
+ import { PressableHighlight } from "./pressable_highlight";
9
+ import { Marker, type StepStatus } from "./stepper";
10
+ import { AnimationFadeIn } from "./animation_fade_in";
11
+ import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
8
12
 
9
13
  export type AgentStepStatus = "running" | "done" | "error";
10
14
 
@@ -14,29 +18,41 @@ export interface AgentRunStep {
14
18
  * it to a human label via the default map / `labelForTool`. A `step` carries
15
19
  * an already-human label. */
16
20
  label: string;
17
- /** A note under the label (a count, an input summary), muted. */
21
+ /** A note under the label — should be REAL tool I/O (an arg, a count, a result
22
+ * summary), never invented prose. Muted. */
18
23
  detail?: string;
19
24
  status: AgentStepStatus;
20
25
  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). */
26
+ /** Optional content shown in a `Peek` popover when the (expanded) step is
27
+ * pressed — glance into what the step did (the tool input, a result summary). */
23
28
  peek?: ReactNode;
24
29
  }
25
30
 
31
+ /**
32
+ * The ordered transcript of a run, in the order it happened: prose the agent
33
+ * streams (`text`) interleaved with the tools/steps it runs between prose
34
+ * (`step`). A real run is a timeline — text, a burst of tool calls, more text,
35
+ * more calls — so the feed is an ordered array, NOT a text block over a flat
36
+ * step list. Consecutive `step` items collapse into ONE activity group.
37
+ */
38
+ export type AgentRunItem =
39
+ | { type: "text"; id: string; text: string }
40
+ | ({ type: "step" } & AgentRunStep);
41
+
26
42
  export interface AgentRunProps {
27
- steps: AgentRunStep[];
28
- /** The agent's streamed reasoning / answer text — shown 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. */
43
+ /** The ordered transcript — text + step items in the order they occurred. */
44
+ items: AgentRunItem[];
45
+ /** Whole-run state. `streaming` keeps the trailing text's caret blinking and
46
+ * the tail activity group pulsing; `done`/`error` settle every group. Defaults
47
+ * to `streaming` while any step is `running`. */
33
48
  state?: "streaming" | "done" | "error";
34
49
  /** Localize / override a TOOL step's display label by its raw tool name
35
50
  * (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
36
51
  * back to the built-in label. */
37
52
  labelForTool?: (toolName: string) => string | undefined;
38
- /** The always-last terminal step's label. Default "Done"; localize per app. */
39
- doneLabel?: string;
53
+ /** Localized "{n} steps" suffix on a collapsed activity group (the kit is
54
+ * locale-neutral). Default `"{n} steps"`. */
55
+ stepsLabel?: (n: number) => string;
40
56
  accessibilityLabel?: string;
41
57
  }
42
58
 
@@ -79,91 +95,237 @@ export function resolveToolMeta(
79
95
  };
80
96
  }
81
97
 
98
+ /** Resolve a step's display label — a `tool` maps its raw name, a `step` is
99
+ * already human. */
100
+ function stepLabel(s: AgentRunStep, labelForTool?: (toolName: string) => string | undefined): string {
101
+ return s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
102
+ }
103
+
104
+ // A run is a timeline of segments: prose, then a group of consecutive tool/step
105
+ // calls, then more prose. Folding the flat item list into segments is what lets
106
+ // the feed interleave instead of forcing all text to the top.
107
+ type Segment =
108
+ | { kind: "text"; id: string; text: string }
109
+ | { kind: "group"; id: string; steps: AgentRunStep[] };
110
+
111
+ function toSegments(items: AgentRunItem[]): Segment[] {
112
+ const segments: Segment[] = [];
113
+ for (const item of items) {
114
+ if (item.type === "text") {
115
+ segments.push({ kind: "text", id: item.id, text: item.text });
116
+ continue;
117
+ }
118
+ const { type: _t, ...step } = item;
119
+ const tail = segments[segments.length - 1];
120
+ if (tail && tail.kind === "group") {
121
+ segments[segments.length - 1] = { ...tail, steps: [...tail.steps, step] };
122
+ } else {
123
+ segments.push({ kind: "group", id: item.id, steps: [step] });
124
+ }
125
+ }
126
+ return segments;
127
+ }
128
+
129
+ const INK = colors.zinc[700];
130
+
82
131
  /**
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`.
132
+ * A live feed of an AI agent's work as a TIMELINE — the prose it streams and the
133
+ * tools it calls, in the order they happened. While the agent is mid-tools the
134
+ * tail group is a SINGLE pulsing row whose label swaps in place as each call
135
+ * fires (not a growing stack of dots); once prose resumes the group settles into
136
+ * one row "{final action} · {n} steps" that EXPANDS on press to the steps in
137
+ * between. Text segments render as prose, the last one carrying a live caret. The
138
+ * run always ends on the agent's text. Pair with `Composer` + `ReviewCard`.
89
139
  */
90
140
  export function AgentRun(props: AgentRunProps) {
91
- const { steps, text, labelForTool, doneLabel = "Done", accessibilityLabel } = props;
92
- const anyRunning = steps.some((s) => s.status === "running");
141
+ const { items, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
142
+ const anyRunning = items.some((it) => it.type === "step" && it.status === "running");
93
143
  const state = props.state ?? (anyRunning ? "streaming" : "done");
94
144
  const streaming = state === "streaming";
95
- const last = steps.length - 1;
145
+ const segments = toSegments(items);
146
+ const lastIndex = segments.length - 1;
96
147
 
97
- return (
98
- <View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
99
- {text ? (
100
- <Text size="sm" style={styles.narration}>
101
- {text}
102
- {streaming ? <Caret /> : null}
103
- </Text>
104
- ) : null}
148
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
149
+ const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
105
150
 
106
- <Stepper orientation="vertical" live={streaming}>
107
- {steps.map((s, i) => {
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}
125
- </View>
126
- );
151
+ return (
152
+ <View accessibilityLabel={accessibilityLabel} style={{ gap: 10 }}>
153
+ {segments.map((seg, i) => {
154
+ if (seg.kind === "text") {
127
155
  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>
156
+ <View key={seg.id} style={styles.narration}>
157
+ <Markdown>{streaming && i === lastIndex ? `${seg.text} ▍` : seg.text}</Markdown>
158
+ </View>
137
159
  );
138
- })}
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>
160
+ }
161
+ return (
162
+ <ToolGroup
163
+ key={seg.id}
164
+ steps={seg.steps}
165
+ active={streaming && i === lastIndex}
166
+ expanded={!!expanded[seg.id]}
167
+ onToggle={() => toggle(seg.id)}
168
+ labelForTool={labelForTool}
169
+ stepsLabel={stepsLabel}
170
+ />
171
+ );
172
+ })}
147
173
  </View>
148
174
  );
149
175
  }
150
176
 
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
- ]),
177
+ // One activity row: [dot] [body] [chevron], dot vertically centred to the body,
178
+ // the whole row a CONTROL_HEIGHT pressable when collapsible (no indent — every row
179
+ // shares the same left edge, collapsed or expanded).
180
+ function ActivityRow(props: {
181
+ markerStatus: StepStatus;
182
+ live?: boolean;
183
+ onPress?: () => void;
184
+ accessibilityLabel?: string;
185
+ trailing?: ReactNode;
186
+ children: ReactNode;
187
+ }) {
188
+ const { markerStatus, live, onPress, accessibilityLabel, trailing, children } = props;
189
+ const inner = (
190
+ <>
191
+ <View style={styles.dotCol}>
192
+ <Marker status={markerStatus} color={INK} live={!!live} />
193
+ </View>
194
+ <View style={styles.rowBody}>{children}</View>
195
+ {trailing ?? null}
196
+ </>
197
+ );
198
+ if (onPress) {
199
+ return (
200
+ <PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
201
+ {inner}
202
+ </PressableHighlight>
160
203
  );
161
- loop.start();
162
- return () => loop.stop();
163
- }, [opacity]);
164
- return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
204
+ }
205
+ return <View style={styles.row}>{inner}</View>;
206
+ }
207
+
208
+ function StepBody({ label, detail, status }: { label: string; detail?: string; status: AgentStepStatus }) {
209
+ return (
210
+ <View style={{ gap: 1 }}>
211
+ <Text size="sm">{label}</Text>
212
+ {detail ? (
213
+ <Text size="xs" color={status === "error" ? "danger" : "muted"}>
214
+ {detail}
215
+ </Text>
216
+ ) : null}
217
+ </View>
218
+ );
219
+ }
220
+
221
+ function SummaryLabel({ label, count, stepsLabel }: { label: string; count: number; stepsLabel: (n: number) => string }) {
222
+ return (
223
+ <Text size="sm" weight="medium" numberOfLines={1}>
224
+ {label}
225
+ <Text size="sm" color="muted">
226
+ {" · " + stepsLabel(count)}
227
+ </Text>
228
+ </Text>
229
+ );
230
+ }
231
+
232
+ function chevron(dir: "down" | "up") {
233
+ return <Icon name={dir === "down" ? "chevron-down" : "chevron-up"} size={16} color={colors.zinc[400]} />;
234
+ }
235
+
236
+ function ToolGroup(props: {
237
+ steps: AgentRunStep[];
238
+ active: boolean;
239
+ expanded: boolean;
240
+ onToggle: () => void;
241
+ labelForTool?: (toolName: string) => string | undefined;
242
+ stepsLabel: (n: number) => string;
243
+ }) {
244
+ const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
245
+ const resolve = (s: AgentRunStep) => stepLabel(s, labelForTool);
246
+
247
+ // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
248
+ // label is keyed by the current step's id, so a new call rises + fades into the
249
+ // SAME row instead of stacking a new dot).
250
+ if (active) {
251
+ const current = steps[steps.length - 1];
252
+ return (
253
+ <View style={styles.group}>
254
+ <ActivityRow markerStatus="current" live>
255
+ <AnimationFadeIn key={current.id} translateY={4}>
256
+ <StepBody label={resolve(current)} detail={current.detail} status={current.status} />
257
+ </AnimationFadeIn>
258
+ </ActivityRow>
259
+ </View>
260
+ );
261
+ }
262
+
263
+ const errored = steps.some((s) => s.status === "error");
264
+ const final = steps[steps.length - 1];
265
+ const expandable = steps.length > 1;
266
+
267
+ // SETTLED, single call — just the one done/warning row (nothing to expand).
268
+ if (!expandable) {
269
+ return (
270
+ <View style={styles.group}>
271
+ <ActivityRow markerStatus={final.status === "error" ? "warning" : "done"}>
272
+ {final.peek ? (
273
+ <Peek accessibilityLabel={resolve(final)} content={final.peek}>
274
+ <StepBody label={resolve(final)} detail={final.detail} status={final.status} />
275
+ </Peek>
276
+ ) : (
277
+ <StepBody label={resolve(final)} detail={final.detail} status={final.status} />
278
+ )}
279
+ </ActivityRow>
280
+ </View>
281
+ );
282
+ }
283
+
284
+ // SETTLED, expandable — a persistent "{final action} · {n} steps" HEADER that
285
+ // STAYS PUT and rolls the steps out BELOW it (in order) on press, so the row you
286
+ // pressed never moves. The header wears the `complete` terminal dot — an outlined
287
+ // ring + check, a differentiator from the filled `done` step dots; every row
288
+ // shares one left edge (no indent).
289
+ return (
290
+ <View style={styles.group}>
291
+ <ActivityRow
292
+ markerStatus={errored ? "warning" : "complete"}
293
+ onPress={onToggle}
294
+ accessibilityLabel={`${resolve(final)} — ${stepsLabel(steps.length)}`}
295
+ trailing={chevron(expanded ? "up" : "down")}
296
+ >
297
+ <SummaryLabel label={resolve(final)} count={steps.length} stepsLabel={stepsLabel} />
298
+ </ActivityRow>
299
+ {expanded
300
+ ? steps.map((s) => (
301
+ <AnimationFadeIn key={s.id} translateY={4}>
302
+ <ActivityRow markerStatus={s.status === "error" ? "warning" : "done"}>
303
+ {s.peek ? (
304
+ <Peek accessibilityLabel={resolve(s)} content={s.peek}>
305
+ <StepBody label={resolve(s)} detail={s.detail} status={s.status} />
306
+ </Peek>
307
+ ) : (
308
+ <StepBody label={resolve(s)} detail={s.detail} status={s.status} />
309
+ )}
310
+ </ActivityRow>
311
+ </AnimationFadeIn>
312
+ ))
313
+ : null}
314
+ </View>
315
+ );
165
316
  }
166
317
 
167
318
  const styles = StyleSheet.create({
168
- narration: { lineHeight: 21, color: colors.zinc[700] },
319
+ narration: { paddingHorizontal: 8 },
320
+ group: { gap: 2 },
321
+ row: {
322
+ flexDirection: "row",
323
+ alignItems: "center",
324
+ gap: 10,
325
+ minHeight: CONTROL_HEIGHT,
326
+ paddingHorizontal: 8,
327
+ borderRadius: CONTROL_RADIUS,
328
+ },
329
+ dotCol: { width: 18, alignItems: "center" },
330
+ rowBody: { flex: 1 },
169
331
  });
@@ -1,4 +1,4 @@
1
- import { Button } from "./button";
1
+ import { IconButton } from "./icon_button";
2
2
  import { View } from "react-native";
3
3
 
4
4
  interface BackButtonProps {
@@ -11,8 +11,9 @@ export function BackButton(props: BackButtonProps) {
11
11
  const { onPress, accessibilityLabel = "Back" } = props;
12
12
  return (
13
13
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
14
- <Button
14
+ <IconButton
15
15
  icon="chevron-left"
16
+ size="lg"
16
17
  color="secondary"
17
18
  accessibilityLabel={accessibilityLabel}
18
19
  onPress={onPress}
package/src/badge.tsx CHANGED
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors, type ColorName } from "./colors";
5
+ import { CONTROL_RADIUS } from "./control_surface";
5
6
  import { useTooltip } from "./tooltip";
6
7
 
7
8
  interface BadgeProps {
@@ -62,7 +63,7 @@ export function Badge(props: BadgeProps) {
62
63
 
63
64
  const styles = StyleSheet.create({
64
65
  base: {
65
- borderRadius: 8,
66
+ borderRadius: CONTROL_RADIUS,
66
67
  paddingVertical: 4,
67
68
  paddingHorizontal: 8,
68
69
  flexDirection: "row",
package/src/button.tsx CHANGED
@@ -9,10 +9,12 @@ import {
9
9
  } from "react-native";
10
10
  import { Text } from "./text";
11
11
  import { colors } from "./colors";
12
+ import { CONTROL_RADIUS } from "./control_surface";
12
13
  import { Icon, IconName } from "./icon";
13
14
  import { Ref, useCallback } from "react";
14
15
  import { TextColor } from "./text_utils";
15
16
  import { useTooltip, UseTooltipOptions } from "./tooltip";
17
+ import { getButtonIconColor } from "./button_colors";
16
18
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
17
19
  export type ButtonIconPosition = "left" | "right";
18
20
 
@@ -22,10 +24,6 @@ interface ButtonPropsBase {
22
24
  icon?: IconName;
23
25
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
24
26
  color?: ButtonColor;
25
- /** Corner shape. `pill` (default) is the fully-rounded capsule — keep it for
26
- * icon-only and toolbar buttons, and to preserve existing screens. `rounded`
27
- * is a 10px radius — use it for labelled CTAs / action buttons. */
28
- shape?: "pill" | "rounded";
29
27
  loading?: boolean;
30
28
  disabled?: boolean;
31
29
  tooltip?: string | UseTooltipOptions;
@@ -34,17 +32,15 @@ interface ButtonPropsBase {
34
32
  }
35
33
 
36
34
  /**
37
- * A button must have an accessible name. The compiler enforces it by
38
- * requiring at least one of: a string `title`, an explicit
39
- * `accessibilityLabel`, or a `tooltip` that carries a visible name. The
40
- * runtime derives `accessibilityLabel` in that precedence order.
35
+ * A `Button` is a LABELLED action `title` is MANDATORY and is its accessible
36
+ * name. For an icon-only affordance use `IconButton` (circular); the compiler
37
+ * blocks a title-less Button so icon-only actions never render as a rounded box.
41
38
  */
42
- export type ButtonProps = ButtonPropsBase &
43
- (
44
- | { title: string; accessibilityLabel?: string; tooltip?: string | UseTooltipOptions }
45
- | { title?: boolean | undefined; accessibilityLabel: string; tooltip?: string | UseTooltipOptions }
46
- | { title?: boolean | undefined; accessibilityLabel?: string; tooltip: string | UseTooltipOptions }
47
- );
39
+ export type ButtonProps = ButtonPropsBase & {
40
+ title: string;
41
+ accessibilityLabel?: string;
42
+ tooltip?: string | UseTooltipOptions;
43
+ };
48
44
 
49
45
  export function Button(props: ButtonProps) {
50
46
  const {
@@ -53,7 +49,6 @@ export function Button(props: ButtonProps) {
53
49
  alignSelf,
54
50
  title,
55
51
  color,
56
- shape = "pill",
57
52
  style,
58
53
  disabled,
59
54
  tooltip,
@@ -117,7 +112,7 @@ export function Button(props: ButtonProps) {
117
112
  const restPrimary = isPrimary && !pressed && !hovered;
118
113
  return [
119
114
  {
120
- borderRadius: shape === "rounded" ? 10 : 999,
115
+ borderRadius: CONTROL_RADIUS,
121
116
  backgroundColor: disabled
122
117
  ? getButtonDisabledBackgroundColor(color)
123
118
  : pressed
@@ -145,22 +140,6 @@ export function Button(props: ButtonProps) {
145
140
  );
146
141
  }
147
142
 
148
- export function getButtonIconColor(color?: ButtonColor, disabled?: boolean): string {
149
- switch (color) {
150
- case "primary":
151
- return colors.white;
152
- case "secondary":
153
- return colors.zinc["900"];
154
- case "danger":
155
- return colors.red["900"];
156
- case "danger-secondary":
157
- return colors.red["900"];
158
- case "muted":
159
- return colors.zinc["600"];
160
- default:
161
- return disabled ? colors.zinc["300"] : colors.zinc["950"];
162
- }
163
- }
164
143
 
165
144
  export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): TextColor {
166
145
  switch (color) {