@lotics/ui 5.7.0 → 5.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.
Files changed (80) hide show
  1. package/AGENTS.md +102 -42
  2. package/examples/tpl_approvals.tsx +10 -10
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_crm_desk.tsx +3 -3
  5. package/examples/tpl_dieline.tsx +7 -6
  6. package/examples/tpl_directory.tsx +3 -3
  7. package/examples/tpl_dossier.tsx +7 -7
  8. package/examples/tpl_draft.tsx +4 -4
  9. package/examples/tpl_extract.tsx +7 -7
  10. package/examples/tpl_inventory.tsx +3 -3
  11. package/examples/tpl_lookup.tsx +4 -3
  12. package/examples/tpl_match.tsx +1 -1
  13. package/examples/tpl_order.tsx +1 -1
  14. package/examples/tpl_ratedesk.tsx +12 -12
  15. package/examples/tpl_record.tsx +1 -1
  16. package/examples/tpl_report.tsx +3 -3
  17. package/examples/tpl_stock.tsx +3 -3
  18. package/examples/tpl_triage.tsx +1 -1
  19. package/package.json +12 -4
  20. package/src/agent_progress.tsx +14 -12
  21. package/src/agent_run.tsx +253 -66
  22. package/src/back_button.tsx +3 -2
  23. package/src/badge.tsx +2 -1
  24. package/src/button.tsx +11 -32
  25. package/src/button_colors.ts +23 -0
  26. package/src/calendar/calendar_view.tsx +2 -2
  27. package/src/card_select_item.tsx +2 -1
  28. package/src/change_review.tsx +5 -6
  29. package/src/{pill_button.tsx → chip.tsx} +7 -7
  30. package/src/chip_group.tsx +2 -2
  31. package/src/column_filter.tsx +4 -4
  32. package/src/combobox.tsx +2 -9
  33. package/src/composer.tsx +4 -3
  34. package/src/control_surface.ts +15 -15
  35. package/src/date_field.tsx +2 -1
  36. package/src/date_filter.tsx +2 -1
  37. package/src/date_range_filter_field.tsx +2 -1
  38. package/src/dialog.tsx +2 -2
  39. package/src/discrepancy.tsx +1 -1
  40. package/src/drawer.tsx +1 -2
  41. package/src/file_gallery_modal.tsx +220 -133
  42. package/src/file_grid.tsx +184 -0
  43. package/src/file_preview.web.tsx +329 -6
  44. package/src/file_preview_types.ts +40 -0
  45. package/src/file_row.tsx +114 -0
  46. package/src/file_rows.tsx +105 -0
  47. package/src/file_thumbnail.tsx +121 -79
  48. package/src/file_thumbnail_grid.tsx +130 -55
  49. package/src/{filter_pill.tsx → filter_chip.tsx} +8 -8
  50. package/src/finding.tsx +1 -1
  51. package/src/gantt/gantt_view.tsx +2 -2
  52. package/src/icon_button.tsx +49 -14
  53. package/src/image_gallery.tsx +0 -4
  54. package/src/inline_edit.tsx +2 -2
  55. package/src/link_button.tsx +2 -1
  56. package/src/menu_button.tsx +2 -1
  57. package/src/menu_list_item.tsx +2 -1
  58. package/src/number_input.tsx +2 -1
  59. package/src/pdfjs_worker.d.ts +5 -0
  60. package/src/picker.tsx +3 -3
  61. package/src/popover_header.tsx +2 -2
  62. package/src/radio_picker.tsx +2 -1
  63. package/src/range_slider.tsx +1 -1
  64. package/src/review_card.tsx +2 -2
  65. package/src/scan_field.tsx +2 -1
  66. package/src/scored_option.tsx +0 -1
  67. package/src/search_input.tsx +3 -2
  68. package/src/segmented_control.tsx +2 -1
  69. package/src/status_badge.tsx +2 -1
  70. package/src/stepper.tsx +3 -2
  71. package/src/switch_button.tsx +2 -1
  72. package/src/switcher.tsx +3 -2
  73. package/src/tabs.tsx +2 -1
  74. package/src/tag_input.tsx +2 -1
  75. package/src/text_input_field.tsx +2 -1
  76. package/src/time_field.tsx +2 -1
  77. package/src/time_picker.tsx +2 -1
  78. package/src/triage_row.tsx +3 -3
  79. package/src/uploading_thumbnail.tsx +212 -0
  80. package/src/hover_action.tsx +0 -54
package/src/agent_run.tsx CHANGED
@@ -1,10 +1,14 @@
1
- import { type ReactNode } from "react";
2
- import { View } from "react-native";
1
+ import { useState, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
3
4
  import { Text } from "./text";
4
- import { type IconName } from "./icon";
5
+ import { Icon, type IconName } from "./icon";
5
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 — rendered as markdown in a
29
- * narration block above the feed, with a trailing while streaming. */
30
- text?: string;
31
- /** Whole-run state. `streaming` keeps the trailing + 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,66 +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;
147
+
148
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
149
+ const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
96
150
 
97
151
  return (
98
- <View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
99
- {text ? <Markdown>{streaming ? `${text} ▍` : text}</Markdown> : null}
100
-
101
- <Stepper orientation="vertical" live={streaming}>
102
- {steps.map((s, i) => {
103
- // Tools the SDK feeds arrive already "done"; show the most recent as
104
- // the pulsing current while the run is still live.
105
- const status: StepStatus =
106
- s.status === "error"
107
- ? "warning"
108
- : s.status === "running" || (streaming && i === last)
109
- ? "current"
110
- : "done";
111
- const label = s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
112
- const body = (
113
- <View style={{ gap: 1 }}>
114
- <Text size="sm">{label}</Text>
115
- {s.detail ? (
116
- <Text size="xs" color={s.status === "error" ? "danger" : "muted"}>
117
- {s.detail}
118
- </Text>
119
- ) : null}
120
- </View>
121
- );
152
+ <View accessibilityLabel={accessibilityLabel} style={{ gap: 10 }}>
153
+ {segments.map((seg, i) => {
154
+ if (seg.kind === "text") {
122
155
  return (
123
- <Step key={s.id} status={status}>
124
- {s.peek ? (
125
- <Peek accessibilityLabel={label} content={s.peek}>
126
- {body}
127
- </Peek>
128
- ) : (
129
- body
130
- )}
131
- </Step>
156
+ <View key={seg.id} style={styles.narration}>
157
+ <Markdown>{streaming && i === lastIndex ? `${seg.text} ▍` : seg.text}</Markdown>
158
+ </View>
132
159
  );
133
- })}
134
- {state === "done" ? (
135
- <Step key="__done" status="complete">
136
- <Text size="sm" weight="medium">
137
- {doneLabel}
138
- </Text>
139
- </Step>
140
- ) : null}
141
- </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
+ })}
142
173
  </View>
143
174
  );
144
175
  }
176
+
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>
203
+ );
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
+ );
316
+ }
317
+
318
+ const styles = StyleSheet.create({
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 },
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) {
@@ -0,0 +1,23 @@
1
+ import { type ButtonColor } from "./button";
2
+ import { colors } from "./colors";
3
+
4
+ /** The icon/glyph ink for each button colour — shared by `Button` and
5
+ * `IconButton` so an icon-only action migrated to `IconButton` keeps the EXACT
6
+ * same colour. Lives in its own module (not `button.tsx`) so a test that mocks
7
+ * `@lotics/ui/button` can't leave `IconButton` without it. */
8
+ export function getButtonIconColor(color?: ButtonColor, disabled?: boolean): string {
9
+ switch (color) {
10
+ case "primary":
11
+ return colors.white;
12
+ case "secondary":
13
+ return colors.zinc["900"];
14
+ case "danger":
15
+ return colors.red["900"];
16
+ case "danger-secondary":
17
+ return colors.red["900"];
18
+ case "muted":
19
+ return colors.zinc["600"];
20
+ default:
21
+ return disabled ? colors.zinc["300"] : colors.zinc["950"];
22
+ }
23
+ }
@@ -125,7 +125,7 @@ const styles = StyleSheet.create({
125
125
  iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
126
126
  todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
127
127
  title: { flex: 1, textAlign: "center" },
128
- viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
129
- viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
128
+ viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 10, padding: 2 },
129
+ viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 },
130
130
  viewBtnActive: { backgroundColor: colors.white },
131
131
  });
@@ -1,5 +1,6 @@
1
1
  import { PressableStateCallbackType, StyleProp, StyleSheet, ViewStyle } from "react-native";
2
2
  import { colors } from "./colors";
3
+ import { CONTROL_RADIUS } from "./control_surface";
3
4
  import { PressableHighlight } from "./pressable_highlight";
4
5
 
5
6
  interface CardSelectItemProps {
@@ -45,7 +46,7 @@ export function CardSelectItem(props: CardSelectItemProps) {
45
46
  const styles = StyleSheet.create({
46
47
  container: {
47
48
  padding: 16,
48
- borderRadius: 8,
49
+ borderRadius: CONTROL_RADIUS,
49
50
  backgroundColor: colors.background,
50
51
  borderColor: colors.border,
51
52
  borderWidth: 1,
@@ -173,8 +173,8 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
173
173
  <View key={props.getKey(item, i)} style={styles.itemCard}>
174
174
  {props.renderItem(item, i)}
175
175
  <View style={styles.itemActions}>
176
- <Button title={props.rejectLabel ?? "Drop"} color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
177
- <Button title={props.acceptLabel ?? "Keep"} color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
176
+ <Button title={props.rejectLabel ?? "Drop"} color="muted" onPress={() => props.onRejectItem?.(i)} />
177
+ <Button title={props.acceptLabel ?? "Keep"} color="secondary" onPress={() => props.onAcceptItem?.(i)} />
178
178
  </View>
179
179
  </View>
180
180
  );
@@ -193,7 +193,7 @@ export function ChangeReview<T>(props: ChangeReviewProps<T>) {
193
193
  )}
194
194
  </View>
195
195
  {props.onUndoItem ? (
196
- <Button title={L.undo} color="muted" shape="rounded" onPress={() => props.onUndoItem?.(i)} />
196
+ <Button title={L.undo} color="muted" onPress={() => props.onUndoItem?.(i)} />
197
197
  ) : null}
198
198
  </View>
199
199
  );
@@ -243,14 +243,13 @@ export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
243
243
  });
244
244
  return (
245
245
  <View style={styles.actionsRow}>
246
- {perItem ? <Button title={props.acceptAllLabel ?? "Accept all"} color="muted" shape="rounded" onPress={acceptAll} /> : null}
246
+ {perItem ? <Button title={props.acceptAllLabel ?? "Accept all"} color="muted" onPress={acceptAll} /> : null}
247
247
  <View style={{ flex: 1 }} />
248
- {props.onDiscard ? <Button title={props.discardLabel ?? "Discard"} color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
248
+ {props.onDiscard ? <Button title={props.discardLabel ?? "Discard"} color="muted" onPress={props.onDiscard} /> : null}
249
249
  {props.onApply ? (
250
250
  <Button
251
251
  title={props.applyLabel ?? (perItem ? "Apply kept" : "Apply")}
252
252
  color="primary"
253
- shape="rounded"
254
253
  disabled={perItem && keptCount === 0}
255
254
  onPress={props.onApply}
256
255
  />
@@ -1,10 +1,10 @@
1
1
  import { Ref } from "react";
2
2
  import { IconButton } from "./icon_button";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
- import { pillSurfaceStyle } from "./control_surface";
4
+ import { chipSurfaceStyle } from "./control_surface";
5
5
  import { StyleSheet, View } from "react-native";
6
6
 
7
- interface PillButtonProps {
7
+ interface ChipProps {
8
8
  testID?: string;
9
9
  children: React.ReactNode;
10
10
  onPress?: () => void;
@@ -13,7 +13,7 @@ interface PillButtonProps {
13
13
  ref?: Ref<View>;
14
14
  }
15
15
 
16
- export function PillButton(props: PillButtonProps) {
16
+ export function Chip(props: ChipProps) {
17
17
  const { testID, children, onPress, onDismiss, dismissTooltip, ref } = props;
18
18
 
19
19
  return (
@@ -22,12 +22,12 @@ export function PillButton(props: PillButtonProps) {
22
22
  <PressableHighlight
23
23
  testID={testID}
24
24
  onPress={onPress}
25
- style={(state) => [pillSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
25
+ style={(state) => [chipSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
26
26
  >
27
27
  {children}
28
28
  </PressableHighlight>
29
29
  ) : (
30
- <View style={[pillSurfaceStyle({}), styles.pillLayout, onDismiss && styles.pillWithDismiss]}>{children}</View>
30
+ <View style={[chipSurfaceStyle({}), styles.pillLayout, onDismiss && styles.pillWithDismiss]}>{children}</View>
31
31
  )}
32
32
  {onDismiss && (
33
33
  <View style={styles.dismissButton}>
@@ -45,8 +45,8 @@ export function PillButton(props: PillButtonProps) {
45
45
  }
46
46
 
47
47
  const styles = StyleSheet.create({
48
- // Surface (height/border/radius/white + hover) comes from pillSurfaceStyle;
49
- // PillButton owns only its row layout.
48
+ // Surface (height/border/radius/white + hover) comes from chipSurfaceStyle;
49
+ // Chip owns only its row layout.
50
50
  pillLayout: {
51
51
  flexDirection: "row",
52
52
  alignItems: "center",
@@ -1,7 +1,7 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
- import { pillSurfaceStyle } from "./control_surface";
4
+ import { chipSurfaceStyle } from "./control_surface";
5
5
 
6
6
  // One-of-N chips: every option visible, one tap to switch, the row wraps on
7
7
  // narrow widths. Quiet zinc styling — bordered white at rest, dark fill when
@@ -55,7 +55,7 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
55
55
  // The shared pill-surface contract (height/border/radius/white +
56
56
  // hover/press wash + the selected box-shadow ring); the chip only
57
57
  // adds its horizontal padding.
58
- style={(state) => [pillSurfaceStyle(state, { selected: active }), { paddingHorizontal: 14 }]}
58
+ style={(state) => [chipSurfaceStyle(state, { selected: active }), { paddingHorizontal: 14 }]}
59
59
  >
60
60
  <Text size="sm" weight={active ? "semibold" : "medium"} color={active ? "default" : "muted"}>
61
61
  {option.label}