@lotics/ui 22.3.0 → 23.1.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 (70) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +22 -22
  4. package/docs/catalog.md +131 -99
  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 +4 -3
  26. package/src/agent_run.tsx +4 -4
  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_row.tsx +29 -4
  38. package/src/file_rows.tsx +1 -1
  39. package/src/finding.tsx +4 -4
  40. package/src/form_field.tsx +1 -1
  41. package/src/format_date.ts +2 -2
  42. package/src/heatmap.tsx +1 -1
  43. package/src/inline_button.tsx +84 -0
  44. package/src/inline_date_picker.tsx +17 -10
  45. package/src/inline_edit.tsx +341 -60
  46. package/src/inline_member_select.tsx +8 -3
  47. package/src/inline_number_input.tsx +11 -4
  48. package/src/inline_select.tsx +26 -13
  49. package/src/inline_text_input.tsx +27 -6
  50. package/src/inline_time_picker.tsx +10 -4
  51. package/src/input_metrics.ts +17 -0
  52. package/src/ledger.tsx +2 -2
  53. package/src/locale.tsx +7 -3
  54. package/src/matrix.tsx +1 -1
  55. package/src/number_input.tsx +18 -7
  56. package/src/pipeline.tsx +1 -1
  57. package/src/popover.tsx +1 -1
  58. package/src/press_door.tsx +1 -1
  59. package/src/pressable_highlight.tsx +1 -1
  60. package/src/progress_bar.tsx +3 -3
  61. package/src/record_summary.tsx +2 -2
  62. package/src/result_header.tsx +2 -2
  63. package/src/sequence.tsx +170 -0
  64. package/src/share_or_download.ts +2 -2
  65. package/src/step_progress.tsx +7 -5
  66. package/src/stepper.tsx +1 -1
  67. package/src/task.tsx +6 -6
  68. package/src/text_input_field.tsx +21 -4
  69. package/src/text_utils.ts +7 -1
  70. 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.3.0",
3
+ "version": "23.1.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",
@@ -265,7 +266,7 @@
265
266
  },
266
267
  "license": "SEE LICENSE IN LICENSE.md",
267
268
  "dependencies": {
268
- "@lotics/docx": "^0.1.0",
269
+ "@lotics/docx": "^0.2.0",
269
270
  "@lotics/xlsx": "^0.1.0",
270
271
  "ai": "^7.0.30",
271
272
  "mdast-util-from-markdown": "^2.0.3",
package/src/agent_run.tsx CHANGED
@@ -140,7 +140,7 @@ const INK = colors.zinc[700];
140
140
  * tools it calls, in the order they happened. While the agent is mid-tools the
141
141
  * tail group is a SINGLE pulsing row whose label swaps in place as each call
142
142
  * fires (not a growing stack of dots); once prose resumes the group settles into
143
- * 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
144
144
  * between. Text segments render as prose. The run always ends on the agent's text.
145
145
  * Pair with `Composer` + `ChangeReview`.
146
146
  */
@@ -262,7 +262,7 @@ function ActivityRow(props: {
262
262
  return <View style={styles.row}>{inner}</View>;
263
263
  }
264
264
 
265
- // 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
266
266
  // on a human decision (its amber dot already signals the state; the word names it).
267
267
  function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
268
268
  return (
@@ -270,7 +270,7 @@ function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
270
270
  {label}
271
271
  {awaiting ? (
272
272
  <Text size="sm" color="muted">
273
- {" · " + awaiting}
273
+ {" (" + awaiting + ")"}
274
274
  </Text>
275
275
  ) : null}
276
276
  </Text>
@@ -453,7 +453,7 @@ function ToolGroup(props: {
453
453
  );
454
454
  }
455
455
 
456
- // SETTLED, expandable — a persistent "{final action} · {n} steps" HEADER that
456
+ // SETTLED, expandable — a persistent "{final action} ({n} steps)" HEADER that
457
457
  // STAYS PUT and rolls the steps out BELOW it (in order) on press, so the row you
458
458
  // pressed never moves. The header wears the `complete` terminal dot — an outlined
459
459
  // ring + check, a differentiator from the filled `done` step dots; every row
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;
@@ -1,4 +1,4 @@
1
- import { View } from "react-native";
1
+ import { StyleSheet, View, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
4
  import { chipSurfaceStyle } from "./control_surface";
@@ -62,7 +62,13 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
62
62
  // The shared pill-surface contract (height/border/radius/white +
63
63
  // hover/press wash + the selected box-shadow ring); the chip only
64
64
  // adds its horizontal padding.
65
- style={(state) => [chipSurfaceStyle(state, { selected: active }), { paddingHorizontal: 14 }]}
65
+ // The ARROW cursor, like every other control that SETS A VALUE. The
66
+ // pointer is reserved for acts that go somewhere or do something —
67
+ // a row that opens a record, a button. A pill here is the field's
68
+ // value, so it reads like the editors beside it, not like a button.
69
+ // (`PressableHighlight` defaults to a pointer; a caller's style lands
70
+ // after its own, so this is the override, not a fight.)
71
+ style={(state) => [chipSurfaceStyle(state, { selected: active }), styles.chip]}
66
72
  >
67
73
  <Text size="sm" weight={active ? "semibold" : "medium"} color={active ? "default" : "muted"}>
68
74
  {option.label}
@@ -73,3 +79,7 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
73
79
  </View>
74
80
  );
75
81
  }
82
+
83
+ const styles = StyleSheet.create({
84
+ chip: { paddingHorizontal: 14, ...({ cursor: "auto" } as ViewStyle) },
85
+ });
@@ -74,8 +74,8 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
74
74
  );
75
75
  }
76
76
 
77
- /** The custom-answer row — the SAME box as a `ChoiceRow` (padding · radius · hover
78
- * wash · focus ring), but holding an always-editable, auto-growing multiline input
77
+ /** The custom-answer row — the SAME box as a `ChoiceRow` (padding, radius, hover
78
+ * wash, focus ring), but holding an always-editable, auto-growing multiline input
79
79
  * instead of a pressable label. Typing it IS the selection; focusing it re-selects a
80
80
  * parked draft (`onFocus`). */
81
81
  function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
@@ -14,7 +14,7 @@ export interface ConfidenceLabels {
14
14
  low: string;
15
15
  }
16
16
 
17
- /** A 0–1 score folded onto the three levels: >=0.8 high · >=0.5 medium · else low. */
17
+ /** A 0–1 score folded onto the three levels: >=0.8 high, >=0.5 medium, else low. */
18
18
  export function levelFromScore(score: number): ConfidenceLevel {
19
19
  if (score >= 0.8) return "high";
20
20
  if (score >= 0.5) return "medium";
@@ -25,7 +25,7 @@ const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
25
25
  const COLOR: Record<ConfidenceLevel, ColorName> = { high: "green", medium: "amber", low: "red" };
26
26
 
27
27
  export interface ConfidenceProps {
28
- /** Pass a level directly, or a 0–1 `score` (>=0.8 high · >=0.5 medium · else low). */
28
+ /** Pass a level directly, or a 0–1 `score` (>=0.8 high, >=0.5 medium, else low). */
29
29
  level?: ConfidenceLevel;
30
30
  score?: number;
31
31
  labels?: Partial<ConfidenceLabels>;
package/src/data_grid.tsx CHANGED
@@ -59,7 +59,7 @@ export interface DataGridProps<T> {
59
59
  * high-volume paginated register (`Table` + `Pagination` + `Drawer`). It owns the
60
60
  * sortable header, the collapsible grouped sections, and the column-aligned rows;
61
61
  * the consumer owns the DATA, the sort/group/filter/collapse STATE, the toolbar
62
- * (search · group-by · filters), the per-group add (`renderGroupFooter`), and every
62
+ * (search, group-by, filters), the per-group add (`renderGroupFooter`), and every
63
63
  * CELL (`columns[].cell` renders ANY field). It renders ALL rows — no pagination /
64
64
  * virtualization — so it's for hundreds, low-thousands; at 10k+ use the register.
65
65
  * Worked example: `tpl_task_board`.