@lotics/ui 22.2.0 → 23.0.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 (68) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +27 -25
  4. package/docs/catalog.md +119 -98
  5. package/docs/composition.md +134 -42
  6. package/docs/data_entry.md +67 -31
  7. package/docs/templates.md +86 -68
  8. package/examples/tpl_allocate.tsx +5 -5
  9. package/examples/tpl_attendance.tsx +2 -2
  10. package/examples/tpl_calendar.tsx +6 -6
  11. package/examples/tpl_dashboard.tsx +9 -9
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_item_list.tsx +64 -37
  14. package/examples/tpl_lookup.tsx +5 -5
  15. package/examples/tpl_pick.tsx +6 -6
  16. package/examples/tpl_pivot.tsx +3 -3
  17. package/examples/tpl_record.tsx +919 -611
  18. package/examples/tpl_report.tsx +2 -2
  19. package/examples/tpl_rollup.tsx +5 -5
  20. package/examples/tpl_shifts.tsx +5 -5
  21. package/examples/tpl_statements.tsx +7 -7
  22. package/examples/tpl_stock.tsx +2 -2
  23. package/examples/tpl_task_board.tsx +42 -26
  24. package/examples/tpl_tower.tsx +6 -6
  25. package/package.json +3 -2
  26. package/src/agent_run.tsx +40 -25
  27. package/src/breakdown.tsx +1 -1
  28. package/src/calendar/calendar_view.tsx +1 -1
  29. package/src/change_review.tsx +9 -8
  30. package/src/chip_group.tsx +12 -2
  31. package/src/choice_list.tsx +2 -2
  32. package/src/confidence.tsx +2 -2
  33. package/src/data_grid.tsx +1 -1
  34. package/src/detail_row.tsx +50 -58
  35. package/src/file_dropzone.tsx +1 -1
  36. package/src/file_gallery_modal.tsx +3 -3
  37. package/src/file_rows.tsx +1 -1
  38. package/src/finding.tsx +4 -4
  39. package/src/form_field.tsx +1 -1
  40. package/src/format_date.ts +2 -2
  41. package/src/heatmap.tsx +1 -1
  42. package/src/inline_button.tsx +84 -0
  43. package/src/inline_date_picker.tsx +17 -10
  44. package/src/inline_edit.tsx +298 -59
  45. package/src/inline_member_select.tsx +8 -3
  46. package/src/inline_number_input.tsx +11 -4
  47. package/src/inline_select.tsx +26 -13
  48. package/src/inline_text_input.tsx +12 -4
  49. package/src/inline_time_picker.tsx +10 -4
  50. package/src/ledger.tsx +2 -2
  51. package/src/locale.tsx +7 -3
  52. package/src/matrix.tsx +1 -1
  53. package/src/number_input.tsx +18 -7
  54. package/src/pipeline.tsx +1 -1
  55. package/src/popover.tsx +1 -1
  56. package/src/press_door.tsx +1 -1
  57. package/src/pressable_highlight.tsx +1 -1
  58. package/src/progress_bar.tsx +3 -3
  59. package/src/record_summary.tsx +2 -2
  60. package/src/result_header.tsx +2 -2
  61. package/src/sequence.tsx +170 -0
  62. package/src/share_or_download.ts +2 -2
  63. package/src/step_progress.tsx +7 -5
  64. package/src/stepper.tsx +1 -1
  65. package/src/task.tsx +6 -6
  66. package/src/text_input_field.tsx +19 -1
  67. package/src/text_utils.ts +1 -1
  68. package/src/linked_record_box.tsx +0 -157
@@ -21,7 +21,7 @@ import { formatMoney } from "@lotics/ui/format_money";
21
21
  import { formatDate, parseDate } from "@lotics/ui/format_date";
22
22
 
23
23
  // ─────────────────────────────────────────────────────────────────────────────
24
- // Template · Lookup report — answer a question by SCOPING a dataset, not by
24
+ // Template, Lookup report — answer a question by SCOPING a dataset, not by
25
25
  // browsing it. The scope bar leads: a period (header band) + ONE search key
26
26
  // chosen from several mutually-exclusive dimensions (SegmentedControl → the
27
27
  // matching picker; switching the key resets the value). That scope drives
@@ -363,7 +363,7 @@ export function TplReport() {
363
363
 
364
364
  <CardFooter>
365
365
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
366
- {`${rows.length.toLocaleString("en-US")} payments · ${formatMoney(total, { compact: true })}${chips.length > 0 ? " (filtered)" : ""}`}
366
+ {`${rows.length.toLocaleString("en-US")} payments totalling ${formatMoney(total, { compact: true })}${chips.length > 0 ? " (filtered)" : ""}`}
367
367
  </Text>
368
368
  <Pagination
369
369
  page={page}
@@ -12,7 +12,7 @@ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
12
12
  import { Sparkline } from "@lotics/ui/sparkline";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────────
15
- // Template · Hierarchy rollup — hierarchical totals with drill: region →
15
+ // Template, Hierarchy rollup — hierarchical totals with drill: region →
16
16
  // site → lane, every level carrying the SAME aligned metric columns so a
17
17
  // bad number can be chased down the tree ("which branch drags the
18
18
  // total?"). Pure composition: nested Accordions + a fixed-width column
@@ -163,7 +163,7 @@ export function TplRollup() {
163
163
  <Card style={{ padding: 0 }}>
164
164
  <CardHeader>
165
165
  <CardHeaderTitle info="The same three columns at every level — totals are volume-weighted, so a flagged region always traces to flagged lanes inside it.">
166
- By region · site · lane
166
+ By region, site, lane
167
167
  </CardHeaderTitle>
168
168
  </CardHeader>
169
169
 
@@ -239,7 +239,7 @@ export function TplRollup() {
239
239
 
240
240
  <CardFooter>
241
241
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
242
- {`Network · ${NETWORK.volume.toLocaleString("en-US")} units · ${NETWORK.ontime}% on-time · ${NETWORK.margin}% margin`}
242
+ {`Network: ${NETWORK.volume.toLocaleString("en-US")} units, ${NETWORK.ontime}% on-time, ${NETWORK.margin}% margin`}
243
243
  </Text>
244
244
  </CardFooter>
245
245
  </Card>
@@ -263,14 +263,14 @@ export function TplRollup() {
263
263
  <Divider />
264
264
  <KVRow label="On-time">
265
265
  <Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
266
- {`${openLane.ontime}% · target ${ONTIME_TARGET}%`}
266
+ {`${openLane.ontime}% (target ${ONTIME_TARGET}%)`}
267
267
  </Text>
268
268
  </KVRow>
269
269
  <Divider />
270
270
  <KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
271
271
  </View>
272
272
  <View style={{ gap: 8 }}>
273
- <Text size="xs" color="muted" weight="medium">Volume · last 12 weeks</Text>
273
+ <Text size="xs" color="muted" weight="medium">Volume (last 12 weeks)</Text>
274
274
  <Sparkline
275
275
  data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
276
276
  width={200}
@@ -20,7 +20,7 @@ import { ProgressBar } from "@lotics/ui/progress_bar";
20
20
  import { Screen } from "@lotics/ui/screen_router";
21
21
 
22
22
  // ─────────────────────────────────────────────────────────────────────────────
23
- // Template · Shift staffing — two-sided allocation: members SIGN UP, the
23
+ // Template, Shift staffing — two-sided allocation: members SIGN UP, the
24
24
  // manager DECIDES and FILLS. Three bands reuse the execution grammar:
25
25
  // the coverage board is the overview (shifts × days, each cell its
26
26
  // filled/required state — press to open the slot), the signup rail is the
@@ -109,7 +109,7 @@ const INITIAL_SIGNUPS: Signup[] = [
109
109
 
110
110
  const dayOf = (k: DayKey) => DAYS.find((d) => d.key === k)!;
111
111
  const shiftOf = (k: ShiftKey) => SHIFTS.find((s) => s.key === k)!;
112
- const slotLabel = (d: DayKey, s: ShiftKey) => `${dayOf(d).label} ${dayOf(d).date} · ${shiftOf(s).label} ${shiftOf(s).hours}`;
112
+ const slotLabel = (d: DayKey, s: ShiftKey) => `${dayOf(d).label} ${dayOf(d).date}, ${shiftOf(s).label} ${shiftOf(s).hours}`;
113
113
 
114
114
  function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
115
115
  return (
@@ -185,7 +185,7 @@ export function TplShifts() {
185
185
  <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
186
186
  {/* header band */}
187
187
  <View style={{ gap: 2 }}>
188
- <Text size="xxl" weight="semibold">Shift staffing · week 15–21/06</Text>
188
+ <Text size="xxl" weight="semibold">Shift staffing (week 15–21/06)</Text>
189
189
  <Text size="sm" color="muted">Members sign up, you allocate — fill every slot without burning anyone out</Text>
190
190
  </View>
191
191
 
@@ -254,7 +254,7 @@ export function TplShifts() {
254
254
  </View>
255
255
  <CardFooter>
256
256
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
257
- {`${totalFilled} of ${totalRequired} positions filled · accepting a signup or adding a member updates the board live`}
257
+ {`${totalFilled} of ${totalRequired} positions filled; accepting a signup or adding a member updates the board live`}
258
258
  </Text>
259
259
  </CardFooter>
260
260
  </Card>
@@ -312,7 +312,7 @@ export function TplShifts() {
312
312
  )}
313
313
  <CardFooter>
314
314
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
315
- {`${signups.length} pending · first come, first served unless coverage says otherwise`}
315
+ {`${signups.length} pending; first come, first served unless coverage says otherwise`}
316
316
  </Text>
317
317
  </CardFooter>
318
318
  </Card>
@@ -6,7 +6,7 @@ import { Button } from "@lotics/ui/button";
6
6
  import { Divider } from "@lotics/ui/divider";
7
7
 
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
- // Template · Financial statements — the income statement, the balance sheet,
9
+ // Template, Financial statements — the income statement, the balance sheet,
10
10
  // and the cash flow statement, all speaking ONE statement grammar: column
11
11
  // captions right-aligned over fixed money columns, items indented under their
12
12
  // group, a hairline rule above every subtotal, the grand total DOUBLE-RULED
@@ -35,8 +35,8 @@ interface Line {
35
35
  const COL_WIDTH = 124;
36
36
 
37
37
  /** One statement: right-aligned column captions, then the lines. The grammar —
38
- * header (group label, no values) · item (indented, plain) · subtotal (rule
39
- * above, semibold) · total (DOUBLE rule above, semibold) — covers all three
38
+ * header (group label, no values), item (indented, plain), subtotal (rule
39
+ * above, semibold), total (DOUBLE rule above, semibold) — covers all three
40
40
  * statements; only the data differs. */
41
41
  function Statement({ columns, lines }: { columns: string[]; lines: Line[] }) {
42
42
  const cells = (l: Line, weight?: "medium" | "semibold") =>
@@ -110,7 +110,7 @@ function Statement({ columns, lines }: { columns: string[]; lines: Line[] }) {
110
110
  );
111
111
  }
112
112
 
113
- /** The statement page: title + the one meta line (entity · period · basis ·
113
+ /** The statement page: title + the one meta line (entity, period, basis,
114
114
  * currency, stated ONCE so cells stay currency-free) + Export, then the
115
115
  * statement column. */
116
116
  function StatementPage({ title, meta, children }: { title: string; meta: string; children: ReactNode }) {
@@ -158,7 +158,7 @@ export function TplIncomeStatement() {
158
158
  { kind: "total", label: "Net income", values: [81_840 * K, 70_160 * K] },
159
159
  ];
160
160
  return (
161
- <StatementPage title="Income statement" meta="VITTORIA Logistics · month ended 30/06/2026 · VND">
161
+ <StatementPage title="Income statement" meta="VITTORIA Logistics, month ended 30/06/2026, VND">
162
162
  <Statement columns={["Jun 2026", "May 2026"]} lines={lines} />
163
163
  </StatementPage>
164
164
  );
@@ -187,7 +187,7 @@ export function TplBalanceSheet() {
187
187
  { kind: "total", label: "Total liabilities & equity", values: [971_900 * K, 926_800 * K] },
188
188
  ];
189
189
  return (
190
- <StatementPage title="Balance sheet" meta="VITTORIA Logistics · as at 30/06/2026 · VND">
190
+ <StatementPage title="Balance sheet" meta="VITTORIA Logistics, as at 30/06/2026, VND">
191
191
  <Statement columns={["30/06/2026", "31/05/2026"]} lines={lines} />
192
192
  </StatementPage>
193
193
  );
@@ -214,7 +214,7 @@ export function TplCashflow() {
214
214
  { kind: "total", label: "Cash at 30/06/2026", values: [400_800 * K] },
215
215
  ];
216
216
  return (
217
- <StatementPage title="Cash flow statement" meta="VITTORIA Logistics · month ended 30/06/2026 · VND">
217
+ <StatementPage title="Cash flow statement" meta="VITTORIA Logistics, month ended 30/06/2026, VND">
218
218
  <Statement columns={["Jun 2026"]} lines={lines} />
219
219
  </StatementPage>
220
220
  );
@@ -18,7 +18,7 @@ import { SearchInput } from "@lotics/ui/search_input";
18
18
  import { formatMoney } from "@lotics/ui/format_money";
19
19
 
20
20
  // ─────────────────────────────────────────────────────────────────────────────
21
- // Template · Drill-down overview — monitoring a population too large to browse
21
+ // Template, Drill-down overview — monitoring a population too large to browse
22
22
  // (10,000+ units). The funnel: KPI strip (population health) → Breakdown
23
23
  // cards (composition by dimension; pressing a segment is the drill-down) →
24
24
  // dismissible facet chips + search → PAGINATED register (the data is the
@@ -334,7 +334,7 @@ export function TplStock() {
334
334
 
335
335
  <CardFooter>
336
336
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
337
- {`${filtered.length.toLocaleString("en-US")} units in this slice · ${formatMoney(filtered.reduce((s, u) => s + u.value, 0), { compact: true })}`}
337
+ {`${filtered.length.toLocaleString("en-US")} units in this slice, worth ${formatMoney(filtered.reduce((s, u) => s + u.value, 0), { compact: true })}`}
338
338
  </Text>
339
339
  <Pagination
340
340
  page={page}
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { Pressable, ScrollView, StyleSheet, View } from "react-native";
2
+ import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { TextLink } from "@lotics/ui/text_link";
5
5
  import { colors, solid, asColorName } from "@lotics/ui/colors";
@@ -24,7 +24,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
24
24
  import { FilesEditor, FilesEditorBar, FilesEditorBarSpacer, FilesEditorRemove, FilesEditorSelect, FilesEditorUpload } from "@lotics/ui/files_editor";
25
25
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
26
26
  import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
27
- import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
27
+ import { CONTROL_RADIUS, HOVER_BORDER } from "@lotics/ui/control_surface";
28
+ import { INLINE_CONTROL_HEIGHT } from "@lotics/ui/inline_edit";
28
29
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
29
30
  import { Composer } from "@lotics/ui/composer";
30
31
  import { AgentRun } from "@lotics/ui/agent_run";
@@ -40,7 +41,7 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
40
41
  type TagOption = { value: string; label: string };
41
42
 
42
43
  // ─────────────────────────────────────────────────────────────────────────────
43
- // Template · Task board — the manager's view, built on the `DataGrid` primitive
44
+ // Template, Task board — the manager's view, built on the `DataGrid` primitive
44
45
  // (the inline-managed grouped table). The board OWNS its data, state, toolbar,
45
46
  // columns and per-group add; `DataGrid` owns the sortable header, the collapsible
46
47
  // grouped sections and the aligned rows.
@@ -170,13 +171,13 @@ function sortValue(t: Task, key: string): string | number {
170
171
  }
171
172
  }
172
173
 
173
- // ── AI · draft tasks from notes — agent proposes, the manager keeps/edits/drops ──
174
+ // ── AI, draft tasks from notes — agent proposes, the manager keeps/edits/drops ──
174
175
  type Phase = "idle" | "reading" | "review" | "done";
175
176
  interface Proposal { id: string; title: string; due: string | null; confidence: ConfidenceLevel; source: string }
176
177
  const DRAFT_STEPS: { label: string; detail: string }[] = [
177
178
  { label: "Split the notes into candidate tasks", detail: "6 lines" },
178
179
  { label: "Drop greetings and lines that aren't actions", detail: "kept 4" },
179
- { label: "Infer a due date from each phrase", detail: "2 dated · 2 left open" },
180
+ { label: "Infer a due date from each phrase", detail: "2 dated, 2 left open" },
180
181
  ];
181
182
  const DRAFTED: Proposal[] = [
182
183
  { id: "p1", title: "Send VITTORIA the revised quote", due: day(1), confidence: "high", source: "“…told VITTORIA they'd have the updated quote by tomorrow.”" },
@@ -269,10 +270,10 @@ export function TplTaskBoard() {
269
270
  }
270
271
 
271
272
  const propertyCols: DataGridColumn<Task>[] = [
272
- { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect variant="cell" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
273
- { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker variant="cell" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
274
- { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect variant="cell" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
275
- { key: "tags", label: "Tags", width: 168, cell: (t) => <InlineSelect multi searchable allowCustom variant="cell" value={t.tags.map((tag) => tag.value)} onSave={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" /> },
273
+ { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect variant="bare" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
274
+ { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker variant="bare" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
275
+ { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect variant="bare" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
276
+ { key: "tags", label: "Tags", width: 168, cell: (t) => <InlineSelect multi searchable allowCustom variant="bare" value={t.tags.map((tag) => tag.value)} onSave={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" /> },
276
277
  ];
277
278
  const filesCol: DataGridColumn<Task> = {
278
279
  key: "files", label: "Files", width: 100, sortable: false,
@@ -299,7 +300,7 @@ export function TplTaskBoard() {
299
300
  const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
300
301
  const addCols = [...visibleProps, filesCol, actionCol, menuCol];
301
302
  const columns: DataGridColumn<Task>[] = [
302
- { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput variant="cell" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
303
+ { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput variant="bare" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
303
304
  ...addCols,
304
305
  ];
305
306
 
@@ -446,11 +447,11 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
446
447
  <View style={{ gap: 10 }}>
447
448
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
448
449
  <View style={{ flex: 1 }}>
449
- <InlineTextInput variant="cell" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
450
+ <InlineTextInput variant="bare" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
450
451
  </View>
451
452
  </View>
452
453
  <View style={{ width: 220 }}>
453
- <InlineDatePicker variant="cell" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
454
+ <InlineDatePicker variant="bare" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
454
455
  </View>
455
456
  <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
456
457
  </View>
@@ -515,13 +516,19 @@ function RowActionCell({ task, result, onAttach, onApprove }: { task: Task; resu
515
516
  </View>
516
517
  );
517
518
  }
518
- // The action is its OWN pressable container (cell-sized hit area); the label is a
519
- // plain `TextLink` (underlined text, no onPress of its own the wrapper handles it).
519
+ // NO CELL TREATMENT. Every other column here is a VALUE and wears the `bare`
520
+ // field surface; an action is not a value, so it gets no frame, no hover fill
521
+ // and no cell-sized hit area — otherwise the row shows two things that look
522
+ // editable and only one is.
523
+ //
524
+ // The LINK owns the press, which is also what puts the pointer cursor on the
525
+ // WORDS rather than across the whole column: a cell-wide pressable makes the
526
+ // cursor change over empty space, promising a target that isn't there.
520
527
  const onPress = a.kind === "attach" ? () => onAttach(task.id) : a.kind === "approve" ? () => onApprove(task.id) : () => {};
521
528
  return (
522
- <Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={a.label} style={({ hovered }: { hovered?: boolean }) => [styles.action, hovered ? styles.actionHover : null]}>
523
- <TextLink size="sm" numberOfLines={1}>{a.label}</TextLink>
524
- </Pressable>
529
+ <View style={styles.action}>
530
+ <TextLink size="sm" numberOfLines={1} onPress={onPress}>{a.label}</TextLink>
531
+ </View>
525
532
  );
526
533
  }
527
534
 
@@ -546,10 +553,10 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
546
553
  // Files / action / ⋯ act on a task that already exists, so the add row leaves
547
554
  // those columns empty — the widths are still reserved so the columns stay aligned.
548
555
  const editor = (key: string) =>
549
- key === "assignee" ? <InlineMemberSelect variant="cell" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
550
- : key === "due" ? <InlineDatePicker variant="cell" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
551
- : key === "status" ? <InlineSelect variant="cell" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
552
- : key === "tags" ? <InlineSelect multi searchable allowCustom variant="cell" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
556
+ key === "assignee" ? <InlineMemberSelect variant="bare" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
557
+ : key === "due" ? <InlineDatePicker variant="bare" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
558
+ : key === "status" ? <InlineSelect variant="bare" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
559
+ : key === "tags" ? <InlineSelect multi searchable allowCustom variant="bare" value={tags.map((tag) => tag.value)} onSave={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} placeholder="Add tags" accessibilityLabel="Tags" />
553
560
  : null;
554
561
 
555
562
  return (
@@ -576,15 +583,24 @@ const styles = StyleSheet.create({
576
583
  addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
577
584
  // Match the CELL height (40) so the files field lines up with the others — and that
578
585
  // height affords a larger thumbnail. Hover = the background-tint WASH (zinc-100), the
579
- // standard CELL language (same as the variant="cell" editors beside these).
580
- clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
581
- clipHover: { backgroundColor: colors.zinc[100] },
586
+ // standard CELL language (same as the variant="bare" editors beside these).
587
+ // The files cell is a VALUE like every other cell in the row, so it wears the
588
+ // `bare` editors' surface exactly: nothing at rest, the kit's border on hover,
589
+ // and the ARROW cursor — a pointer would read as a button and single this cell
590
+ // out from the editors either side of it.
591
+ clip: {
592
+ flexDirection: "row", alignItems: "center", gap: 6, minHeight: INLINE_CONTROL_HEIGHT,
593
+ paddingHorizontal: 8, borderRadius: CONTROL_RADIUS,
594
+ borderWidth: 1, borderColor: "transparent",
595
+ ...({ cursor: "auto", transitionProperty: "border-color", transitionDuration: "0.12s" } as ViewStyle),
596
+ },
597
+ clipHover: { borderColor: HOVER_BORDER },
582
598
  clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
583
599
  summary: { flexDirection: "row", alignItems: "center", gap: 4 },
584
600
  summaryDot: { width: 8, height: 8, borderRadius: 999 },
585
601
  result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
586
602
  // Fills the cell (height + width) so the link's hit area matches the cells; wash hover.
587
- action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, cursor: "pointer" },
588
- actionHover: { backgroundColor: colors.zinc[100] },
603
+ // Alignment only the action column paints nothing.
604
+ action: { justifyContent: "center", minHeight: INLINE_CONTROL_HEIGHT, flexDirection: "row" },
589
605
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
590
606
  });
@@ -15,7 +15,7 @@ import { StatusGrid, StatusLegend } from "@lotics/ui/status_grid";
15
15
  import { Timeline } from "@lotics/ui/timeline";
16
16
 
17
17
  // ─────────────────────────────────────────────────────────────────────────────
18
- // Template · Status grid — continuous monitoring of hundreds of live
18
+ // Template, Status grid — continuous monitoring of hundreds of live
19
19
  // units (machines, gates, chargers, sensors). Three bands answer the three
20
20
  // ops questions in order: KPI strip + StatusGrid = "is everything OK right
21
21
  // now?" (scan for red; the legend drills a state), exceptions rail =
@@ -262,7 +262,7 @@ export function TplTower() {
262
262
  </View>
263
263
  <CardFooter>
264
264
  <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
265
- {`12 of ${EXCEPTIONS.length} · the drawer's › walks the full queue`}
265
+ {`12 of ${EXCEPTIONS.length} the drawer's › walks the full queue`}
266
266
  </Text>
267
267
  <Button title="View all" color="muted" onPress={() => {}} />
268
268
  </CardFooter>
@@ -273,7 +273,7 @@ export function TplTower() {
273
273
  <Card style={{ padding: 0 }}>
274
274
  <CardHeader>
275
275
  <CardHeaderTitle info="Alarm count by site and day. Intensity scales to the worst cell — press one to see what drove that day.">
276
- Alarm pattern · last 14 days
276
+ Alarm pattern (last 14 days)
277
277
  </CardHeaderTitle>
278
278
  </CardHeader>
279
279
  <View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 12 }}>
@@ -288,8 +288,8 @@ export function TplTower() {
288
288
  <Text size="sm" color="muted" tabular>
289
289
  {hotCell
290
290
  ? hotValue === 0
291
- ? `${siteOf(hotCell.row as SiteKey).label} · ${hotCell.col} — no alarms`
292
- : `${siteOf(hotCell.row as SiteKey).label} · ${hotCell.col} — ${hotValue} alarms · most frequent: ${WARN_MSGS[hash(DAYS.indexOf(hotCell.col), 8) % WARN_MSGS.length]}`
291
+ ? `${siteOf(hotCell.row as SiteKey).label}, ${hotCell.col} — no alarms`
292
+ : `${siteOf(hotCell.row as SiteKey).label}, ${hotCell.col} — ${hotValue} alarms; most frequent: ${WARN_MSGS[hash(DAYS.indexOf(hotCell.col), 8) % WARN_MSGS.length]}`
293
293
  : "Press a cell to see what drove that day"}
294
294
  </Text>
295
295
  </View>
@@ -312,7 +312,7 @@ export function TplTower() {
312
312
  <Badge label={siteOf(unit.site).label} />
313
313
  </View>
314
314
  {unit.message ? (
315
- <Text size="sm" color="danger">{`${unit.message} · ${formatAge(unit.ageMin ?? 0)} ago`}</Text>
315
+ <Text size="sm" color="danger">{`${unit.message} (${formatAge(unit.ageMin ?? 0)} ago)`}</Text>
316
316
  ) : null}
317
317
  <View style={{ gap: 6 }}>
318
318
  <KVRow label="Site"><Text size="sm">{siteOf(unit.site).label}</Text></KVRow>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "22.2.0",
3
+ "version": "23.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -137,7 +137,7 @@
137
137
  "./badge": "./src/badge.tsx",
138
138
  "./callout": "./src/callout.tsx",
139
139
  "./inset": "./src/inset.tsx",
140
- "./linked_record_box": "./src/linked_record_box.tsx",
140
+ "./inline_button": "./src/inline_button.tsx",
141
141
  "./inline_edit": "./src/inline_edit.tsx",
142
142
  "./inline_static": "./src/inline_static.tsx",
143
143
  "./inline_text_input": "./src/inline_text_input.tsx",
@@ -199,6 +199,7 @@
199
199
  "./list_item": "./src/list_item.tsx",
200
200
  "./section_heading": "./src/section_heading.tsx",
201
201
  "./section_stack": "./src/section_stack.tsx",
202
+ "./sequence": "./src/sequence.tsx",
202
203
  "./separator": "./src/separator.tsx",
203
204
  "./stack": "./src/stack.tsx",
204
205
  "./status_badge": "./src/status_badge.tsx",
package/src/agent_run.tsx CHANGED
@@ -4,6 +4,7 @@ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { Button } from "./button";
6
6
  import { Icon, type IconName } from "./icon";
7
+ import { Callout, CalloutActions, CalloutText } from "./callout";
7
8
  import { Markdown } from "./markdown";
8
9
  import { JsonPanel, stringifyData } from "./json_panel";
9
10
  import { PressableHighlight } from "./pressable_highlight";
@@ -139,7 +140,7 @@ const INK = colors.zinc[700];
139
140
  * tools it calls, in the order they happened. While the agent is mid-tools the
140
141
  * tail group is a SINGLE pulsing row whose label swaps in place as each call
141
142
  * fires (not a growing stack of dots); once prose resumes the group settles into
142
- * one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
143
+ * one row — "{final action} ({n} steps)" — that EXPANDS on press to the steps in
143
144
  * between. Text segments render as prose. The run always ends on the agent's text.
144
145
  * Pair with `Composer` + `ChangeReview`.
145
146
  */
@@ -187,27 +188,44 @@ export function AgentRun(props: AgentRunProps) {
187
188
  />
188
189
  );
189
190
  })}
190
- {/* A run-level BREAKING error terminates the feed with a danger row. It lives
191
- outside `parts` (the stream failed), so the caller passes it explicitly.
192
- A retry action rides UNDER it when the caller can restart the run. */}
191
+ {/* A run-level BREAKING error terminates the feed. It lives outside `parts`
192
+ (the stream failed), so the caller passes it explicitly.
193
+ A CONTAINED surface, not another step row: the run didn't advance, it
194
+ stopped, and a tinted panel says that at a glance where a bare red line
195
+ in the step gutter reads as one more step that happened to fail. It also
196
+ gives the retry a container — free-floating, a full-weight button in a
197
+ column of text reads as a stray control — and `Callout` carries
198
+ `role="alert"`, which the plain row did not.
199
+ FULL WIDTH, unlike `reasoning` / `rowDetail`: those indent to a step's
200
+ text column because they are that step's sub-content. This hangs under
201
+ no step — it is the whole run ending — so indenting it would make a
202
+ run-level failure read as the last step's detail. */}
193
203
  {props.error ? (
194
- <View style={{ gap: 8 }}>
195
- <View style={styles.row}>
196
- <View style={styles.dotCol}>
197
- <Icon name="circle-alert" size={17} color={colors.red[500]} />
198
- </View>
199
- <View style={styles.rowBody}>
200
- <Text size="sm" color="danger">
201
- {props.error}
202
- </Text>
203
- </View>
204
- </View>
204
+ <Callout tone="error">
205
+ <CalloutText>{props.error}</CalloutText>
205
206
  {onRetry ? (
206
- <View style={styles.retry}>
207
- <Button title={locale.agentRun.retry} color="secondary" icon="rotate-ccw" alignSelf="flex-start" onPress={onRetry} />
208
- </View>
207
+ <CalloutActions>
208
+ {/* PRIMARY, deliberately. `Callout` is a calm panel by design (a
209
+ 50 tint behind a soft 100 border), so a filled button inside it
210
+ is not emphasis twice — it is the one action on a quiet
211
+ surface. Every unfilled variant loses here: default and muted
212
+ hover to zinc-100, a grey wash over the red tint, and
213
+ danger-secondary puts red ink on a red-50 ground, the weakest
214
+ contrast of the options. The run just died; the only way out
215
+ should be the most legible thing in the panel.
216
+ Still a Button, never a link: a link navigates, this re-fires a
217
+ run, and that distinction is what a screen reader and the
218
+ keyboard both rely on. */}
219
+ <Button
220
+ title={locale.agentRun.retry}
221
+ color="primary"
222
+ icon="rotate-ccw"
223
+ alignSelf="flex-start"
224
+ onPress={onRetry}
225
+ />
226
+ </CalloutActions>
209
227
  ) : null}
210
- </View>
228
+ </Callout>
211
229
  ) : null}
212
230
  </View>
213
231
  );
@@ -244,7 +262,7 @@ function ActivityRow(props: {
244
262
  return <View style={styles.row}>{inner}</View>;
245
263
  }
246
264
 
247
- // The step label, plus a muted "· {awaiting}" annotation when the call is parked
265
+ // The step label, plus a muted "(awaiting …)" qualifier when the call is parked
248
266
  // on a human decision (its amber dot already signals the state; the word names it).
249
267
  function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
250
268
  return (
@@ -252,7 +270,7 @@ function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
252
270
  {label}
253
271
  {awaiting ? (
254
272
  <Text size="sm" color="muted">
255
- {" · " + awaiting}
273
+ {" (" + awaiting + ")"}
256
274
  </Text>
257
275
  ) : null}
258
276
  </Text>
@@ -435,7 +453,7 @@ function ToolGroup(props: {
435
453
  );
436
454
  }
437
455
 
438
- // SETTLED, expandable — a persistent "{final action} · {n} steps" HEADER that
456
+ // SETTLED, expandable — a persistent "{final action} ({n} steps)" HEADER that
439
457
  // STAYS PUT and rolls the steps out BELOW it (in order) on press, so the row you
440
458
  // pressed never moves. The header wears the `complete` terminal dot — an outlined
441
459
  // ring + check, a differentiator from the filled `done` step dots; every row
@@ -486,7 +504,4 @@ const styles = StyleSheet.create({
486
504
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
487
505
  // A step's expanded I/O panels — same left edge as the reasoning body.
488
506
  rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
489
- // The retry action under the terminal error — aligned to the message body
490
- // (past the dot column + its gap), so it reads as a response to that row.
491
- retry: { paddingLeft: 28 },
492
507
  });
package/src/breakdown.tsx CHANGED
@@ -46,7 +46,7 @@ const DEFAULT_LABELS: BreakdownLabels = { more: (hidden) => `Show ${hidden} more
46
46
  /**
47
47
  * Composition browser for LARGE populations — the answer to "what is my
48
48
  * stock made of, and let me drill into a slice". A stacked bar shows the
49
- * shares; below it, one pressable row per segment (swatch · label · count ·
49
+ * shares; below it, one pressable row per segment (swatch, label, count,
50
50
  * share). Selecting a segment dims the rest and (in the host) filters the
51
51
  * register underneath — the facet pattern for 10,000-record monitoring.
52
52
  * Several Breakdowns side by side (by type, by status, by site) compose a
@@ -27,7 +27,7 @@ export interface CalendarViewProps<T = unknown> {
27
27
  }
28
28
 
29
29
  /**
30
- * Full calendar: a toolbar (prev / today / next + month·week·day switch) over
30
+ * Full calendar: a toolbar (prev / today / next + month/week/day switch) over
31
31
  * the month grid or the week/day time grid. Date + view are owned here so the
32
32
  * primitive is drop-in; the consumer only supplies events + callbacks.
33
33
  */
@@ -43,7 +43,7 @@ export type ChangeStatus = "pending" | "accepted" | "rejected";
43
43
  // context only mirrors it so the header counter, Keep-all, and the
44
44
  // apply-disabled logic derive without the host re-wiring them. An add, an
45
45
  // update, a removal and a source conflict are compositions of ONE grammar:
46
- // `ChangeFields` + `ChangeField` (the − band · the + value · candidate rows)
46
+ // `ChangeFields` + `ChangeField` (the − band, the + value, candidate rows)
47
47
  // — not separate components; `ChangeRecord` wraps the same grammar in the
48
48
  // per-item card for record SETS.
49
49
  // ─────────────────────────────────────────────────────────────────────────────
@@ -241,7 +241,7 @@ export function ChangeFields({ children }: { children: ReactNode }) {
241
241
  export interface ChangeRecordProps {
242
242
  /** Stable identity — registers in the review context like a `Change`. */
243
243
  id: string;
244
- /** "add" (+, green) · "remove" (−, red) · "edit" (±, neutral — only the
244
+ /** "add" (+, green), "remove" (−, red), "edit" (±, neutral — only the
245
245
  * changed fields, each with its own mini-diff via `before`). */
246
246
  tone: "add" | "remove" | "edit";
247
247
  /** The card header title (the item's name). */
@@ -290,16 +290,17 @@ export function ChangeRecord(props: ChangeRecordProps) {
290
290
  : { glyph: "±", word: loc.opEdit, color: colors.zinc[600], bg: colors.white, header: colors.zinc[50], border: colors.border };
291
291
  return (
292
292
  <View style={[styles.recordCard, { backgroundColor: mark.bg, borderColor: mark.border }]}>
293
- {/* The op reads on the WHOLE card — a quiet tone wash (50 body ·
294
- 100 header · 200 border); an edit stays white, its diffs carry it. */}
293
+ {/* The op reads on the WHOLE card — a quiet tone wash (50 body,
294
+ 100 header, 200 border); an edit stays white, its diffs carry it. */}
295
295
  <View style={[styles.recordCardHeader, { backgroundColor: mark.header }]}>
296
296
  <Text size="sm" weight="semibold" tabular style={{ color: mark.color, width: 14 }}>
297
297
  {mark.glyph}
298
298
  </Text>
299
+ {/* The op word RUNS IN to the title it names, so it takes the colon and
300
+ the title follows on the row's own gap. */}
299
301
  <Text size="sm" weight="semibold" style={{ color: mark.color }}>
300
- {mark.word}
302
+ {mark.word}:
301
303
  </Text>
302
- <Text size="sm" color="muted">·</Text>
303
304
  <Text size="sm" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>
304
305
  {props.title}
305
306
  </Text>
@@ -380,9 +381,9 @@ export interface ChangeFieldProps {
380
381
  * (when replacing/removing), the + value (editable, read-only on a conflict,
381
382
  * or absent on a pure removal), conflict candidates + the type-another-value
382
383
  * third option, the quiet reasoning — deciding for ITSELF via Keep/Drop.
383
- * A plain field (no diff, no decision) is the inline label · value row,
384
+ * A plain field (no diff, no decision) is the inline label, value row,
384
385
  * press-to-edit when `onChangeText` is given. Decided → ONE quiet collapsed
385
- * row: mark · label → value · Undo.
386
+ * row: mark, label → value, Undo.
386
387
  */
387
388
  export function ChangeField(props: ChangeFieldProps) {
388
389
  const loc = useLoticsLocale().changeReview;