@lotics/ui 5.4.0 → 5.6.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
@@ -221,10 +222,15 @@ never removes a file. Three reusable primitives back it (the logic is shared; th
221
222
  copy stay local):
222
223
  - **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit, toggle,
223
224
  toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's `selectedIds`).
224
- - **`shareOrDownloadFiles(files, { title })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
225
+ - **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) — `navigator.share({ files })`
225
226
  (mobile → Save to gallery / send to an app), else individual `downloadFileFromUrl` — **never a ZIP**.
226
- It shares the BYTES (fetches each presigned URL → `File` at the tap), so URL expiry afterward is moot;
227
- the share path needs the host iframe to grant `allow="web-share"` (else it falls back to download).
227
+ It shares the BYTES (fetches each URL → `File`), so URL expiry afterward is moot; the share path needs the
228
+ host iframe to grant `allow="web-share"` (else it falls back to download). Best for FAST urls (presigned
229
+ R2). **For SLOW urls (auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
230
+ {credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])` runs
231
+ `navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch burns the
232
+ tap's transient activation**, which silently lands in the download fallback. `prepareShareFiles` throws on
233
+ a fetch failure (log it, don't swallow); `shareFiles` returns `"shared"|"cancelled"|"unsupported"`.
228
234
  - **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation into a
229
235
  NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is persisted). Pair with
230
236
  `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a rotated image).
@@ -264,9 +270,13 @@ Compose the surfaces as a loop, and reach for the right one by job:
264
270
  attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
265
271
  way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
266
272
  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
273
+ - **Show the work** — `AgentRun`: a live feed of the agent's work its streamed narration (with a
274
+ blinking caret) + tool calls as a vertical `Stepper` of neutral dots (the latest pulsing while live,
275
+ a white-check dot once done). No title, no avatar just the work. A terminal "Done" step (the
276
+ `complete` node) appears ONLY once the run finishes. A step can carry `peek` content to glance into
277
+ via a popover. Tool labels resolve from the raw tool name via a built-in default map + an optional
278
+ `labelForTool` override (localize THERE — the kit stays English). `doneLabel` localizes the terminal.
279
+ Transparent work, NEVER a bare spinner — the
270
280
  intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
271
281
  collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
272
282
  morphs into it while running, and reveals again when done.
@@ -365,9 +375,11 @@ controls — while **status/severity/diffs use functional colour** the way the r
365
375
  - **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
366
376
  - **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
367
377
  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).
378
+ - **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
379
+ centre), `done` a filled accent dot + **white check**, `upcoming` a faint **grey** ring, the terminal
380
+ `complete` a **blackish ring + black check**, `warning` **amber**. Default accent is neutral ink
381
+ (`color` themes it); `AgentRun` keeps the neutral default + appends the `complete` "Done" node only
382
+ when the run finishes.
371
383
  - **ChangeReview diffs** — the old value struck in **red**, the new value in **green**.
372
384
  - **Discrepancy** — the agent's pick gets a **blue** highlight; `Mismatch` reads amber, `Resolved`
373
385
  emerald.
@@ -584,8 +596,8 @@ inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s
584
596
  list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
585
597
  floating_action_bar · filter_pill · column_filter (ColumnFilter — the typed per-column filter pill +
586
598
  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 ·
599
+ 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) ·
600
+ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
589
601
  command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
590
602
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
591
603
  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.6.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
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { shareOrDownloadFiles } from "./share_or_download";
2
+ import { shareOrDownloadFiles, prepareShareFiles, shareFiles } from "./share_or_download";
3
3
 
4
4
  afterEach(() => {
5
5
  vi.restoreAllMocks();
@@ -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"); });
@@ -37,3 +48,45 @@ describe("shareOrDownloadFiles", () => {
37
48
  expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
38
49
  });
39
50
  });
51
+
52
+ describe("prepareShareFiles + shareFiles (iOS: fetch ahead of the gesture, share synchronously)", () => {
53
+ it("prepareShareFiles fetches the bytes into File[] with the given credentials", async () => {
54
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
55
+ vi.stubGlobal("fetch", fetchMock);
56
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
57
+
58
+ const files = await prepareShareFiles([{ url: "https://api/v1/files/k", filename: "a.png" }], { credentials: "include" });
59
+
60
+ expect(files).not.toBeNull();
61
+ expect(files?.[0]).toBeInstanceOf(File);
62
+ expect(files?.[0].name).toBe("a.png");
63
+ expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
64
+ });
65
+
66
+ it("prepareShareFiles returns null when Web Share (files) is unsupported", async () => {
67
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"]))));
68
+ vi.stubGlobal("navigator", {}); // no canShare / share
69
+ expect(await prepareShareFiles([{ url: "u", filename: "a.png" }])).toBeNull();
70
+ });
71
+
72
+ it("prepareShareFiles THROWS on a fetch failure (caller logs it, not a silent download)", async () => {
73
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
74
+ vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
75
+ await expect(prepareShareFiles([{ url: "u", filename: "a.png" }])).rejects.toThrow(/401/);
76
+ });
77
+
78
+ it("shareFiles shares the SAME pre-fetched File (no re-fetch) and maps cancel/unsupported", async () => {
79
+ const share = vi.fn(async (_d: { files: File[] }) => undefined);
80
+ vi.stubGlobal("navigator", { canShare: () => true, share });
81
+ const file = new File(["x"], "a.png", { type: "image/png" });
82
+
83
+ expect(await shareFiles([file], { title: "T" })).toBe("shared");
84
+ expect(share.mock.calls[0][0].files[0]).toBe(file); // the exact pre-fetched File crosses the boundary
85
+
86
+ vi.stubGlobal("navigator", { share: vi.fn(async () => { throw new DOMException("x", "AbortError"); }) });
87
+ expect(await shareFiles([file])).toBe("cancelled");
88
+
89
+ vi.stubGlobal("navigator", {}); // no share
90
+ expect(await shareFiles([file])).toBe("unsupported");
91
+ });
92
+ });
@@ -5,8 +5,15 @@
5
5
  //
6
6
  // Web Share inside an iframe requires the host to grant `allow="web-share"` on
7
7
  // the iframe element; without it `navigator.canShare({ files })` is false and we
8
- // fall through to downloads. Must be invoked from a user gesture (a click) — the
9
- // blob fetches happen inside that gesture's task so the share keeps activation.
8
+ // fall through to downloads.
9
+ //
10
+ // iOS Safari quirk: `navigator.share` MUST be called within the tap's *transient
11
+ // activation*. Awaiting a SLOW blob fetch before it (e.g. an auth-gated proxy URL
12
+ // with a server-side R2 round-trip) burns the activation → `share()` rejects →
13
+ // the caller falls back to a download. So for slow URLs, fetch the File[] BEFORE
14
+ // the gesture with `prepareShareFiles`, then call `shareFiles` synchronously on
15
+ // the tap. `shareOrDownloadFiles` is the all-in-one convenience for FAST URLs
16
+ // (presigned R2) where the fetch fits inside the activation window.
10
17
  //
11
18
  // Pure web, no React Native / @lotics/shared imports — consumable by both the
12
19
  // host frontend and sandboxed custom-code apps via the per-file export.
@@ -23,6 +30,10 @@ export type ShareOrDownloadResult =
23
30
  | { delivered: "share"; count: number }
24
31
  | { delivered: "download"; count: number };
25
32
 
33
+ /** "shared" = the sheet completed · "cancelled" = the user dismissed it ·
34
+ * "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
35
+ export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
36
+
26
37
  async function toFile(f: ShareableFile, credentials: RequestCredentials): Promise<File> {
27
38
  const res = await fetch(f.url, { cache: "no-store", credentials });
28
39
  if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
@@ -30,6 +41,49 @@ async function toFile(f: ShareableFile, credentials: RequestCredentials): Promis
30
41
  return new File([blob], f.filename, { type: f.mimeType || blob.type || "application/octet-stream" });
31
42
  }
32
43
 
44
+ /**
45
+ * Fetch `files` into File objects ready for {@link shareFiles}, to be done BEFORE
46
+ * the share gesture so the share call itself stays synchronous (the iOS note
47
+ * above). Returns the File[] when Web Share (files) is supported and the set is
48
+ * shareable; returns `null` when Web Share is unavailable or the set isn't
49
+ * shareable (the caller should download instead). THROWS if a fetch fails, so the
50
+ * caller can log the real reason rather than silently downloading.
51
+ */
52
+ export async function prepareShareFiles(
53
+ files: ShareableFile[],
54
+ opts?: { credentials?: RequestCredentials },
55
+ ): Promise<File[] | null> {
56
+ const valid = files.filter((f) => f.url);
57
+ if (valid.length === 0) return null;
58
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
59
+ if (!nav || typeof nav.canShare !== "function" || typeof nav.share !== "function") return null;
60
+ const fileObjs = await Promise.all(valid.map((f) => toFile(f, opts?.credentials ?? "same-origin")));
61
+ if (fileObjs.length === 0 || !nav.canShare({ files: fileObjs })) return null;
62
+ return fileObjs;
63
+ }
64
+
65
+ /**
66
+ * Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
67
+ * user gesture (no awaited fetch before it) so iOS keeps the transient activation.
68
+ * "cancelled" = user dismissed · "unsupported" = no Web Share; a real failure THROWS.
69
+ */
70
+ export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
71
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
72
+ if (!nav || typeof nav.share !== "function" || fileObjs.length === 0) return "unsupported";
73
+ try {
74
+ await nav.share({ files: fileObjs, title: opts?.title, text: opts?.text });
75
+ return "shared";
76
+ } catch (err) {
77
+ if (err instanceof DOMException && err.name === "AbortError") return "cancelled";
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * All-in-one: fetch + share, else download each file (never a ZIP). Best for FAST
84
+ * URLs (presigned R2) where the fetch fits inside the iOS activation window; for
85
+ * slow URLs, prepare ahead with {@link prepareShareFiles} + {@link shareFiles}.
86
+ */
33
87
  export async function shareOrDownloadFiles(
34
88
  files: ShareableFile[],
35
89
  opts?: { title?: string; text?: string; credentials?: RequestCredentials },
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
- });