@lotics/ui 8.0.0 → 10.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 (58) hide show
  1. package/AGENTS.md +177 -70
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_item_list.tsx +1015 -124
  7. package/examples/tpl_pick.tsx +3 -3
  8. package/examples/tpl_pivot.tsx +1 -1
  9. package/examples/tpl_record.tsx +1354 -0
  10. package/examples/tpl_report.tsx +7 -7
  11. package/examples/tpl_rollup.tsx +6 -6
  12. package/examples/tpl_shifts.tsx +2 -2
  13. package/examples/tpl_statements.tsx +221 -0
  14. package/examples/tpl_stock.tsx +7 -7
  15. package/examples/tpl_task_board.tsx +16 -13
  16. package/examples/tpl_tasks.tsx +15 -28
  17. package/examples/tpl_tower.tsx +2 -2
  18. package/package.json +8 -7
  19. package/src/capture_row.tsx +59 -0
  20. package/src/checklist.tsx +104 -0
  21. package/src/chip.tsx +12 -3
  22. package/src/detail_row.tsx +137 -10
  23. package/src/inline_date_picker.tsx +8 -3
  24. package/src/inline_edit.tsx +40 -10
  25. package/src/inline_member_select.tsx +3 -0
  26. package/src/inline_number_input.tsx +5 -2
  27. package/src/inline_select.tsx +8 -3
  28. package/src/inline_tag_select.tsx +140 -0
  29. package/src/inline_text_input.tsx +5 -2
  30. package/src/inline_time_picker.tsx +5 -2
  31. package/src/ledger.tsx +220 -0
  32. package/src/locale.tsx +25 -0
  33. package/src/page_header.tsx +0 -2
  34. package/src/popover_nav.tsx +40 -0
  35. package/src/progress_bar.tsx +32 -1
  36. package/src/record_summary.tsx +101 -0
  37. package/src/section_heading.tsx +16 -8
  38. package/src/suggestion_chip.tsx +47 -0
  39. package/src/trend_footer.tsx +3 -1
  40. package/src/use_screen_size.ts +1 -1
  41. package/src/use_section_nav.test.ts +69 -0
  42. package/src/use_section_nav.ts +59 -0
  43. package/examples/tpl_billing.tsx +0 -344
  44. package/examples/tpl_detail.tsx +0 -232
  45. package/examples/tpl_directory.tsx +0 -260
  46. package/examples/tpl_intake.tsx +0 -206
  47. package/examples/tpl_order.tsx +0 -482
  48. package/examples/tpl_quick.tsx +0 -211
  49. package/examples/tpl_record_plain.tsx +0 -259
  50. package/examples/tpl_settings.tsx +0 -178
  51. package/examples/tpl_timeline.tsx +0 -244
  52. package/examples/tpl_wizard.tsx +0 -223
  53. package/src/animation_horizontal_slide.tsx +0 -75
  54. package/src/form_time_picker.tsx +0 -22
  55. package/src/highlighted_text.tsx +0 -92
  56. package/src/menu_title.tsx +0 -15
  57. package/src/pager_view.tsx +0 -167
  58. package/src/popover_header.tsx +0 -38
@@ -321,13 +321,13 @@ export function TplReport() {
321
321
 
322
322
  {/* eyebrow columns */}
323
323
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
324
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 92 }}>Reference</Text>
325
- <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1.3 }}>Customer</Text>
326
- <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Operator</Text>
327
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 70 }}>Date</Text>
328
- <Text size="xs" color="muted" transform="uppercase" tabular align="right" style={{ width: 116 }}>Amount</Text>
329
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 92 }}>Receipt</Text>
330
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 96 }}>Status</Text>
324
+ <Text size="xs" color="muted" weight="medium" style={{ width: 92 }}>Reference</Text>
325
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1.3 }}>Customer</Text>
326
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Operator</Text>
327
+ <Text size="xs" color="muted" weight="medium" style={{ width: 70 }}>Date</Text>
328
+ <Text size="xs" color="muted" weight="medium" tabular align="right" style={{ width: 116 }}>Amount</Text>
329
+ <Text size="xs" color="muted" weight="medium" style={{ width: 92 }}>Receipt</Text>
330
+ <Text size="xs" color="muted" weight="medium" style={{ width: 96 }}>Status</Text>
331
331
  </View>
332
332
  <Divider />
333
333
 
@@ -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 · Network 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
@@ -169,10 +169,10 @@ export function TplRollup() {
169
169
 
170
170
  {/* eyebrow columns — match the tree's column map exactly */}
171
171
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
172
- <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Branch</Text>
173
- <Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.volume }}>Volume</Text>
174
- <Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.ontime }}>On-time</Text>
175
- <Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.margin }}>Margin</Text>
172
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Branch</Text>
173
+ <Text size="xs" color="muted" weight="medium" align="right" style={{ width: COLS.volume }}>Volume</Text>
174
+ <Text size="xs" color="muted" weight="medium" align="right" style={{ width: COLS.ontime }}>On-time</Text>
175
+ <Text size="xs" color="muted" weight="medium" align="right" style={{ width: COLS.margin }}>Margin</Text>
176
176
  <View style={{ width: 16 }} />
177
177
  </View>
178
178
  <Divider />
@@ -270,7 +270,7 @@ export function TplRollup() {
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" transform="uppercase">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}
@@ -210,7 +210,7 @@ export function TplShifts() {
210
210
  <View style={{ width: 110 }} />
211
211
  {DAYS.map((d) => (
212
212
  <View key={d.key} style={{ flex: 1, alignItems: "center" }}>
213
- <Text size="xs" color="muted" transform="uppercase">{d.label}</Text>
213
+ <Text size="xs" color="muted" weight="medium">{d.label}</Text>
214
214
  <Text size="xs" color="muted" tabular>{d.date}</Text>
215
215
  </View>
216
216
  ))}
@@ -389,7 +389,7 @@ export function TplShifts() {
389
389
  </PopoverTrigger>
390
390
  <PopoverContent style={{ width: 320 }}>
391
391
  <View style={{ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
392
- <Text size="xs" color="muted" transform="uppercase">{`Fill ${slotLabel(open.day, open.shift)}`}</Text>
392
+ <Text size="xs" color="muted" weight="medium">{`Fill ${slotLabel(open.day, open.shift)}`}</Text>
393
393
  </View>
394
394
  <ScrollView style={{ maxHeight: 280 }} contentContainerStyle={{ paddingHorizontal: 6, paddingBottom: 8, gap: 2 }}>
395
395
  {MEMBERS.filter((m) => !openAssigned.includes(m)).map((m) => {
@@ -0,0 +1,221 @@
1
+ import { ReactNode } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Divider } from "@lotics/ui/divider";
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Template · Financial statements — the income statement, the balance sheet,
10
+ // and the cash flow statement, all speaking ONE statement grammar: column
11
+ // captions right-aligned over fixed money columns, items indented under their
12
+ // group, a hairline rule above every subtotal, the grand total DOUBLE-RULED
13
+ // (the accounting convention), negatives in accounting parentheses, no bars,
14
+ // no charts — the numbers are the interface. The three statements TIE: net
15
+ // income flows into retained earnings, the cash flow's closing cash IS the
16
+ // balance sheet's cash, the loan repayment moves the debt line. Figures are
17
+ // per-cell currency-free (the meta line says "VND" once).
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ /** Accounting formatting: thousands-dotted, negatives in parentheses. */
21
+ const num = (n: number) => {
22
+ const s = Math.abs(n).toLocaleString("vi-VN");
23
+ return n < 0 ? `(${s})` : s;
24
+ };
25
+
26
+ type LineKind = "header" | "item" | "subtotal" | "total";
27
+
28
+ interface Line {
29
+ kind: LineKind;
30
+ label: string;
31
+ /** One value per column; null renders an empty cell (headers usually). */
32
+ values?: (number | null)[];
33
+ }
34
+
35
+ const COL_WIDTH = 124;
36
+
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
40
+ * statements; only the data differs. */
41
+ function Statement({ columns, lines }: { columns: string[]; lines: Line[] }) {
42
+ const cells = (l: Line, weight?: "medium" | "semibold") =>
43
+ (l.values ?? columns.map(() => null)).map((v, i) => (
44
+ <Text key={i} size="sm" weight={weight} tabular align="right" style={{ width: COL_WIDTH }}>
45
+ {v == null ? "" : num(v)}
46
+ </Text>
47
+ ));
48
+ return (
49
+ <View>
50
+ {/* column captions */}
51
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingBottom: 6 }}>
52
+ <View style={{ flex: 1 }} />
53
+ {columns.map((c) => (
54
+ <Text key={c} size="xs" weight="medium" color="muted" align="right" style={{ width: COL_WIDTH }}>
55
+ {c}
56
+ </Text>
57
+ ))}
58
+ </View>
59
+ <Divider />
60
+ <View style={{ paddingTop: 6, gap: 2 }}>
61
+ {lines.map((l, i) => {
62
+ if (l.kind === "header") {
63
+ return (
64
+ <Text key={i} size="sm" weight="semibold" style={{ paddingTop: i === 0 ? 0 : 10 }}>
65
+ {l.label}
66
+ </Text>
67
+ );
68
+ }
69
+ if (l.kind === "item") {
70
+ return (
71
+ <View key={i} style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 26 }}>
72
+ <Text size="sm" style={{ flex: 1, paddingLeft: 12 }}>{l.label}</Text>
73
+ {cells(l)}
74
+ </View>
75
+ );
76
+ }
77
+ if (l.kind === "subtotal") {
78
+ return (
79
+ <View key={i} style={{ gap: 2 }}>
80
+ <View style={{ flexDirection: "row", gap: 12 }}>
81
+ <View style={{ flex: 1 }} />
82
+ <View style={{ width: COL_WIDTH * columns.length + 12 * (columns.length - 1), borderTopWidth: 1, borderTopColor: colors.zinc[200] }} />
83
+ </View>
84
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 26 }}>
85
+ <Text size="sm" weight="medium" style={{ flex: 1 }}>{l.label}</Text>
86
+ {cells(l, "medium")}
87
+ </View>
88
+ </View>
89
+ );
90
+ }
91
+ // total — the double rule
92
+ return (
93
+ <View key={i} style={{ gap: 2, paddingTop: 4 }}>
94
+ <View style={{ flexDirection: "row", gap: 12 }}>
95
+ <View style={{ flex: 1 }} />
96
+ <View style={{ width: COL_WIDTH * columns.length + 12 * (columns.length - 1), gap: 2 }}>
97
+ <View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[400] }} />
98
+ <View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[400] }} />
99
+ </View>
100
+ </View>
101
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 30 }}>
102
+ <Text size="sm" weight="semibold" style={{ flex: 1 }}>{l.label}</Text>
103
+ {cells(l, "semibold")}
104
+ </View>
105
+ </View>
106
+ );
107
+ })}
108
+ </View>
109
+ </View>
110
+ );
111
+ }
112
+
113
+ /** The statement page: title + the one meta line (entity · period · basis ·
114
+ * currency, stated ONCE so cells stay currency-free) + Export, then the
115
+ * statement column. */
116
+ function StatementPage({ title, meta, children }: { title: string; meta: string; children: ReactNode }) {
117
+ return (
118
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
119
+ <View style={{ width: "100%", maxWidth: 680, alignSelf: "center", gap: 20 }}>
120
+ <View style={{ flexDirection: "row", alignItems: "flex-start", flexWrap: "wrap", columnGap: 12, rowGap: 8 }}>
121
+ <View style={{ flexGrow: 1, flexBasis: 240, gap: 2 }}>
122
+ <Text size="xl" weight="semibold">{title}</Text>
123
+ <Text size="sm" color="muted">{meta}</Text>
124
+ </View>
125
+ <Button title="Export" color="secondary" icon="file-down" onPress={() => {}} />
126
+ </View>
127
+ {children}
128
+ <Text size="xs" color="muted">Unaudited management accounts. Figures in VND.</Text>
129
+ </View>
130
+ </ScrollView>
131
+ );
132
+ }
133
+
134
+ // ── the data — internally exact AND tied across the three statements:
135
+ // net income 81.840 → retained earnings 203.600 + 81.840 − 40.740 dividends
136
+ // = 244.700; closing cash 400.800 IS the balance sheet's cash; the loan
137
+ // repayment takes long-term debt 90.000 → 80.000. (thousands of VND)
138
+ const K = 1000;
139
+
140
+ export function TplIncomeStatement() {
141
+ const lines: Line[] = [
142
+ { kind: "header", label: "Revenue" },
143
+ { kind: "item", label: "Service revenue", values: [842_500 * K, 781_200 * K] },
144
+ { kind: "item", label: "Disbursement recharges", values: [128_400 * K, 118_900 * K] },
145
+ { kind: "subtotal", label: "Total revenue", values: [970_900 * K, 900_100 * K] },
146
+ { kind: "header", label: "Cost of services" },
147
+ { kind: "item", label: "Carrier & handling", values: [-512_300 * K, -471_800 * K] },
148
+ { kind: "item", label: "Customs & levies", values: [-148_200 * K, -139_600 * K] },
149
+ { kind: "subtotal", label: "Gross profit", values: [310_400 * K, 288_700 * K] },
150
+ { kind: "header", label: "Operating expenses" },
151
+ { kind: "item", label: "Salaries & benefits", values: [-142_000 * K, -138_500 * K] },
152
+ { kind: "item", label: "Office & software", values: [-38_400 * K, -37_200 * K] },
153
+ { kind: "item", label: "Marketing", values: [-21_500 * K, -18_900 * K] },
154
+ { kind: "subtotal", label: "Operating income", values: [108_500 * K, 94_100 * K] },
155
+ { kind: "item", label: "Interest expense", values: [-6_200 * K, -6_400 * K] },
156
+ { kind: "subtotal", label: "Profit before tax", values: [102_300 * K, 87_700 * K] },
157
+ { kind: "item", label: "Income tax (20%)", values: [-20_460 * K, -17_540 * K] },
158
+ { kind: "total", label: "Net income", values: [81_840 * K, 70_160 * K] },
159
+ ];
160
+ return (
161
+ <StatementPage title="Income statement" meta="VITTORIA Logistics · month ended 30/06/2026 · VND">
162
+ <Statement columns={["Jun 2026", "May 2026"]} lines={lines} />
163
+ </StatementPage>
164
+ );
165
+ }
166
+
167
+ export function TplBalanceSheet() {
168
+ const lines: Line[] = [
169
+ { kind: "header", label: "Assets" },
170
+ { kind: "item", label: "Cash & equivalents", values: [400_800 * K, 355_100 * K] },
171
+ { kind: "item", label: "Accounts receivable", values: [386_200 * K, 401_600 * K] },
172
+ { kind: "item", label: "Advances & deposits", values: [54_500 * K, 48_200 * K] },
173
+ { kind: "subtotal", label: "Current assets", values: [841_500 * K, 804_900 * K] },
174
+ { kind: "item", label: "Equipment, net", values: [130_400 * K, 121_900 * K] },
175
+ { kind: "total", label: "Total assets", values: [971_900 * K, 926_800 * K] },
176
+ { kind: "header", label: "Liabilities" },
177
+ { kind: "item", label: "Accounts payable", values: [268_300 * K, 259_400 * K] },
178
+ { kind: "item", label: "Taxes payable", values: [42_100 * K, 38_600 * K] },
179
+ { kind: "item", label: "Accrued salaries", values: [36_800 * K, 35_200 * K] },
180
+ { kind: "subtotal", label: "Current liabilities", values: [347_200 * K, 333_200 * K] },
181
+ { kind: "item", label: "Long-term debt", values: [80_000 * K, 90_000 * K] },
182
+ { kind: "subtotal", label: "Total liabilities", values: [427_200 * K, 423_200 * K] },
183
+ { kind: "header", label: "Equity" },
184
+ { kind: "item", label: "Contributed capital", values: [300_000 * K, 300_000 * K] },
185
+ { kind: "item", label: "Retained earnings", values: [244_700 * K, 203_600 * K] },
186
+ { kind: "subtotal", label: "Total equity", values: [544_700 * K, 503_600 * K] },
187
+ { kind: "total", label: "Total liabilities & equity", values: [971_900 * K, 926_800 * K] },
188
+ ];
189
+ return (
190
+ <StatementPage title="Balance sheet" meta="VITTORIA Logistics · as at 30/06/2026 · VND">
191
+ <Statement columns={["30/06/2026", "31/05/2026"]} lines={lines} />
192
+ </StatementPage>
193
+ );
194
+ }
195
+
196
+ export function TplCashflow() {
197
+ const lines: Line[] = [
198
+ { kind: "header", label: "Operating activities" },
199
+ { kind: "item", label: "Net income", values: [81_840 * K] },
200
+ { kind: "item", label: "Depreciation", values: [3_500 * K] },
201
+ { kind: "item", label: "Decrease in accounts receivable", values: [15_400 * K] },
202
+ { kind: "item", label: "Increase in accounts payable", values: [8_900 * K] },
203
+ { kind: "item", label: "Other working-capital movements", values: [-1_200 * K] },
204
+ { kind: "subtotal", label: "Net cash from operating activities", values: [108_440 * K] },
205
+ { kind: "header", label: "Investing activities" },
206
+ { kind: "item", label: "Purchases of equipment", values: [-12_000 * K] },
207
+ { kind: "subtotal", label: "Net cash used in investing activities", values: [-12_000 * K] },
208
+ { kind: "header", label: "Financing activities" },
209
+ { kind: "item", label: "Repayment of long-term debt", values: [-10_000 * K] },
210
+ { kind: "item", label: "Dividends paid", values: [-40_740 * K] },
211
+ { kind: "subtotal", label: "Net cash used in financing activities", values: [-50_740 * K] },
212
+ { kind: "subtotal", label: "Net increase in cash", values: [45_700 * K] },
213
+ { kind: "item", label: "Cash at 01/06/2026", values: [355_100 * K] },
214
+ { kind: "total", label: "Cash at 30/06/2026", values: [400_800 * K] },
215
+ ];
216
+ return (
217
+ <StatementPage title="Cash flow statement" meta="VITTORIA Logistics · month ended 30/06/2026 · VND">
218
+ <Statement columns={["Jun 2026"]} lines={lines} />
219
+ </StatementPage>
220
+ );
221
+ }
@@ -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 · Stock 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
@@ -283,12 +283,12 @@ export function TplStock() {
283
283
 
284
284
  {/* eyebrow columns */}
285
285
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
286
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 110 }}>Unit no</Text>
287
- <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Type</Text>
288
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 96 }}>Depot</Text>
289
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 76 }}>Last move</Text>
290
- <Text size="xs" color="muted" transform="uppercase" tabular align="right" style={{ width: 104 }}>Value</Text>
291
- <Text size="xs" color="muted" transform="uppercase" style={{ width: 110 }}>Status</Text>
286
+ <Text size="xs" color="muted" weight="medium" style={{ width: 110 }}>Unit no</Text>
287
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Type</Text>
288
+ <Text size="xs" color="muted" weight="medium" style={{ width: 96 }}>Depot</Text>
289
+ <Text size="xs" color="muted" weight="medium" style={{ width: 76 }}>Last move</Text>
290
+ <Text size="xs" color="muted" weight="medium" tabular align="right" style={{ width: 104 }}>Value</Text>
291
+ <Text size="xs" color="muted" weight="medium" style={{ width: 110 }}>Status</Text>
292
292
  <View style={{ width: 28 }} />
293
293
  </View>
294
294
  <Divider />
@@ -25,6 +25,7 @@ import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
25
25
  import { FilesEditor } from "@lotics/ui/files_editor";
26
26
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
27
27
  import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
28
+ import { CONTROL_RADIUS, HOVER_BORDER } from "@lotics/ui/control_surface";
28
29
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
29
30
  import { Composer } from "@lotics/ui/composer";
30
31
  import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
@@ -264,9 +265,9 @@ export function TplTaskBoard() {
264
265
  }
265
266
 
266
267
  const propertyCols: DataGridColumn<Task>[] = [
267
- { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
268
- { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
269
- { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
268
+ { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
269
+ { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
270
+ { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
270
271
  { key: "tags", label: "Tags", width: 168, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
271
272
  ];
272
273
  const filesCol: DataGridColumn<Task> = {
@@ -294,7 +295,7 @@ export function TplTaskBoard() {
294
295
  const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
295
296
  const addCols = [...visibleProps, filesCol, actionCol, menuCol];
296
297
  const columns: DataGridColumn<Task>[] = [
297
- { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
298
+ { key: "title", label: "Task", sortable: true, cell: (t) => <InlineTextInput background="transparent" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={t.status === "done"} accessibilityLabel="Title" /> },
298
299
  ...addCols,
299
300
  ];
300
301
 
@@ -435,12 +436,12 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
435
436
  <View style={{ gap: 10 }}>
436
437
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
437
438
  <View style={{ flex: 1 }}>
438
- <InlineTextInput value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
439
+ <InlineTextInput background="transparent" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
439
440
  </View>
440
441
  <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
441
442
  </View>
442
443
  <View style={{ width: 220 }}>
443
- <InlineDatePicker value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
444
+ <InlineDatePicker background="transparent" value={proposal.due} optionalTime onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
444
445
  </View>
445
446
  <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
446
447
  </View>
@@ -527,9 +528,9 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
527
528
  // Files / action / ⋯ act on a task that already exists, so the add row leaves
528
529
  // those columns empty — the widths are still reserved so the columns stay aligned.
529
530
  const editor = (key: string) =>
530
- key === "assignee" ? <InlineMemberSelect members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
531
- : key === "due" ? <InlineDatePicker value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
532
- : key === "status" ? <InlineSelect value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
531
+ key === "assignee" ? <InlineMemberSelect background="transparent" members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
532
+ : key === "due" ? <InlineDatePicker background="transparent" value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
533
+ : key === "status" ? <InlineSelect background="transparent" value={status} options={STATUS_OPTIONS} onSave={setStatus} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" />
533
534
  : key === "tags" ? <Select multi searchable allowCustom value={tags.map((tag) => tag.value)} onValueChange={(next) => setTags(next.map(tagOf))} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" />
534
535
  : null;
535
536
 
@@ -557,14 +558,16 @@ const styles = StyleSheet.create({
557
558
  addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
558
559
  // Match the inline-editor cells' resting box (INLINE_CONTROL_HEIGHT = 40) so the
559
560
  // files field lines up with the others — and that height affords a larger thumbnail.
560
- clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
561
- clipHover: { backgroundColor: colors.zinc[100] },
561
+ // Hover = the control-surface BORDER reveal, the same language as the
562
+ // inline-editor cells beside these — never a background wash of its own.
563
+ clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, borderWidth: 1, borderColor: "transparent", cursor: "pointer" },
564
+ clipHover: { borderColor: HOVER_BORDER },
562
565
  clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
563
566
  summary: { flexDirection: "row", alignItems: "center", gap: 4 },
564
567
  summaryDot: { width: 8, height: 8, borderRadius: 999 },
565
568
  result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
566
569
  // Fills the cell (height + width) so the link's hit area matches the inline cells.
567
- action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
568
- actionHover: { backgroundColor: colors.white },
570
+ action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS, borderWidth: 1, borderColor: "transparent", cursor: "pointer" },
571
+ actionHover: { borderColor: HOVER_BORDER },
569
572
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
570
573
  });
@@ -12,7 +12,7 @@ import { InlineTextInput } from "@lotics/ui/inline_text_input";
12
12
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
13
13
  import { InlineSelect } from "@lotics/ui/inline_select";
14
14
  import { Select } from "@lotics/ui/select";
15
- import { TextInputField } from "@lotics/ui/text_input_field";
15
+ import { CaptureRow } from "@lotics/ui/capture_row";
16
16
  import { Button } from "@lotics/ui/button";
17
17
  import { FilesEditor } from "@lotics/ui/files_editor";
18
18
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
@@ -27,7 +27,7 @@ import { formatDate, toISODate } from "@lotics/ui/format_date";
27
27
  type TagOption = { value: string; label: string };
28
28
 
29
29
  // ─────────────────────────────────────────────────────────────────────────────
30
- // Template · Tasks (quick list) — the Apple-Reminders shape for PERSONAL lists.
30
+ // Template · Task list — the Apple-Reminders shape for PERSONAL lists.
31
31
  // There's no Task component: a row is composed directly from primitives
32
32
  // (CheckCircle + an inline-editable title + a quiet meta line), which is the most
33
33
  // flexible way to build a task surface. Quiet, scannable rows you tick to finish
@@ -259,15 +259,7 @@ export function TplTasks() {
259
259
 
260
260
  {/* Capture — type to add; the submit button lives inside the field on the right. */}
261
261
  <View style={styles.capture}>
262
- <View style={[styles.row, { gap: 12 }]}>
263
- <View style={styles.emptyRing} />
264
- <View style={styles.captureField}>
265
- <View style={{ flex: 1 }}>
266
- <TextInputField value={draft} onChangeText={setDraft} onSubmitEditing={add} placeholder="Add a task…" style={styles.titleInput} accessibilityLabel="Add a task" />
267
- </View>
268
- {draft.trim() ? <Button title="Save" color="primary" onPress={add} /> : null}
269
- </View>
270
- </View>
262
+ <CaptureRow value={draft} onChangeText={setDraft} onSubmit={add} placeholder="Add a task…" accessibilityLabel="Add a task" />
271
263
  </View>
272
264
 
273
265
  <View>
@@ -294,7 +286,7 @@ export function TplTasks() {
294
286
  <View style={styles.row}>
295
287
  <CheckCircle checked={done} onChange={(on) => patch(t.id, { status: on ? "done" : "todo" })} accessibilityLabel={t.title} />
296
288
  <View style={{ flex: 1 }}>
297
- <InlineTextInput value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={done} accessibilityLabel="Title" />
289
+ <InlineTextInput background="transparent" value={t.title} onSave={(v) => patch(t.id, { title: v })} struck={done} accessibilityLabel="Title" />
298
290
  </View>
299
291
  <Pressable
300
292
  onPress={() => toggleExpand(t.id, !open)}
@@ -320,16 +312,16 @@ export function TplTasks() {
320
312
  {open ? (
321
313
  <View style={styles.detail}>
322
314
  <Field label="Due">
323
- <InlineDatePicker value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="Set a due date" accessibilityLabel="Due date" />
315
+ <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="Set a due date" accessibilityLabel="Due date" />
324
316
  </Field>
325
317
  <Field label="Status">
326
- <InlineSelect value={t.status} options={STATUS_OPTIONS} onSave={(st) => patch(t.id, { status: st })} renderSelected={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} renderOptionContent={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} accessibilityLabel="Status" />
318
+ <InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(st) => patch(t.id, { status: st })} renderSelected={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} renderOptionContent={(o) => <OptionBadge value={STATUS[o.value as Status]} variant="dot" />} accessibilityLabel="Status" />
327
319
  </Field>
328
320
  <Field label="Tags">
329
321
  <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" />
330
322
  </Field>
331
323
  <Field label="Note">
332
- <InlineTextInput value={t.note} onSave={(v) => patch(t.id, { note: v })} placeholder="Add a note…" accessibilityLabel="Note" />
324
+ <InlineTextInput background="transparent" value={t.note} onSave={(v) => patch(t.id, { note: v })} placeholder="Add a note…" accessibilityLabel="Note" />
333
325
  </Field>
334
326
  <Field label="Files" top>
335
327
  <FilesEditor files={t.files} itemSize={72} onAdd={(picked) => addFiles(t.id, picked)} onRemove={(id) => removeFile(t.id, id)} />
@@ -409,12 +401,12 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
409
401
  <View style={{ gap: 10 }}>
410
402
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
411
403
  <View style={{ flex: 1 }}>
412
- <InlineTextInput value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
404
+ <InlineTextInput background="transparent" value={proposal.title} onSave={(v) => onEdit(proposal.id, { title: v })} accessibilityLabel="Task title" />
413
405
  </View>
414
406
  <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
415
407
  </View>
416
408
  <View style={{ width: 200 }}>
417
- <InlineDatePicker value={proposal.due} onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
409
+ <InlineDatePicker background="transparent" value={proposal.due} onSave={(v) => onEdit(proposal.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" />
418
410
  </View>
419
411
  <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
420
412
  </View>
@@ -442,25 +434,20 @@ const styles = StyleSheet.create({
442
434
  rowHover: { backgroundColor: colors.white },
443
435
  // The expand affordance — its own button (meta + chevron), sibling to the ring
444
436
  // and title so no interactive control nests inside another.
445
- expand: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8 },
437
+ // FIXED width so every title cell ends at the same x the meta content
438
+ // (clip count · tag dots · date) varies per row and right-aligns inside;
439
+ // without it the title editors read ragged/misaligned across rows.
440
+ expand: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 10, minHeight: 36, paddingHorizontal: 8, borderRadius: 8, width: 148 },
446
441
  capture: {
447
442
  borderBottomWidth: 1,
448
443
  borderBottomColor: colors.zinc[100],
449
444
  paddingBottom: 6,
450
445
  },
451
- // Borderless title input that matches an InlineTextInput's resting inset (8),
452
- // so the capture title lines up with the task titles below it.
453
- titleInput: {
454
- borderWidth: 0,
455
- backgroundColor: "transparent",
456
- paddingVertical: 0,
457
- },
458
- captureField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
446
+
459
447
  sectionHead: { flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 6 },
460
448
  sectionLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
461
- meta: { flexDirection: "row", alignItems: "center", gap: 10 },
449
+ meta: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 10, flex: 1 },
462
450
  clip: { flexDirection: "row", alignItems: "center", gap: 2 },
463
- emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
464
451
  dots: { flexDirection: "row", alignItems: "center", gap: 4 },
465
452
  dot: { width: 8, height: 8, borderRadius: 999 },
466
453
  // ring (20) + gap (12) + the title's 8 inset → labels line up with the title text
@@ -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 · Control tower — 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 =
@@ -200,7 +200,7 @@ export function TplTower() {
200
200
  return (
201
201
  <View key={site.key} style={{ gap: 8 }}>
202
202
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
203
- <Text size="xs" color="muted" transform="uppercase">{site.label}</Text>
203
+ <Text size="xs" color="muted" weight="medium">{site.label}</Text>
204
204
  <Text size="xs" color="muted" tabular>{siteUnits.length}</Text>
205
205
  </View>
206
206
  <StatusGrid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "8.0.0",
3
+ "version": "10.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -30,6 +30,9 @@
30
30
  "./rotatable_image": "./src/rotatable_image.tsx",
31
31
  "./use_image_rotation": "./src/use_image_rotation.ts",
32
32
  "./use_selection": "./src/use_selection.ts",
33
+ "./use_section_nav": "./src/use_section_nav.ts",
34
+ "./ledger": "./src/ledger.tsx",
35
+ "./inline_tag_select": "./src/inline_tag_select.tsx",
33
36
  "./use_selection_mode": "./src/use_selection_mode.ts",
34
37
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
35
38
  "./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
@@ -66,7 +69,6 @@
66
69
  "./theme": "./src/theme.tsx",
67
70
  "./progress_bar": "./src/progress_bar.tsx",
68
71
  "./form_date_picker": "./src/form_date_picker.tsx",
69
- "./form_time_picker": "./src/form_time_picker.tsx",
70
72
  "./form_picker": "./src/form_picker.tsx",
71
73
  "./picker": "./src/picker.tsx",
72
74
  "./select": "./src/select.tsx",
@@ -154,6 +156,10 @@
154
156
  "./table": "./src/table.tsx",
155
157
  "./data_grid": "./src/data_grid.tsx",
156
158
  "./detail_row": "./src/detail_row.tsx",
159
+ "./record_summary": "./src/record_summary.tsx",
160
+ "./capture_row": "./src/capture_row.tsx",
161
+ "./checklist": "./src/checklist.tsx",
162
+ "./suggestion_chip": "./src/suggestion_chip.tsx",
157
163
  "./danger_zone": "./src/danger_zone.tsx",
158
164
  "./scan_field": "./src/scan_field.tsx",
159
165
  "./completion_state": "./src/completion_state.tsx",
@@ -163,7 +169,6 @@
163
169
  "./container": "./src/container.tsx",
164
170
  "./count": "./src/count.tsx",
165
171
  "./dots_indicator": "./src/dots_indicator.tsx",
166
- "./highlighted_text": "./src/highlighted_text.tsx",
167
172
  "./list": "./src/list.tsx",
168
173
  "./list_item": "./src/list_item.tsx",
169
174
  "./section": "./src/section.tsx",
@@ -178,11 +183,9 @@
178
183
  "./tabs": "./src/tabs.tsx",
179
184
  "./segmented_control": "./src/segmented_control.tsx",
180
185
  "./auto_sizer": "./src/auto_sizer.tsx",
181
- "./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
182
186
  "./group_avatar": "./src/group_avatar.tsx",
183
187
  "./page_content": "./src/page_content.tsx",
184
188
  "./page_header": "./src/page_header.tsx",
185
- "./pager_view": "./src/pager_view.tsx",
186
189
  "./date_picker": "./src/date_picker.tsx",
187
190
  "./date_filter": "./src/date_filter.tsx",
188
191
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
@@ -193,7 +196,6 @@
193
196
  "./modal": "./src/modal.tsx",
194
197
  "./screen_router": "./src/screen_router.tsx",
195
198
  "./route_matching": "./src/route_matching.ts",
196
- "./menu_title": "./src/menu_title.tsx",
197
199
  "./avatar": {
198
200
  "react-native": "./src/avatar.tsx",
199
201
  "default": "./src/avatar.web.tsx"
@@ -209,7 +211,6 @@
209
211
  "./json_panel": "./src/json_panel.tsx",
210
212
  "./timeline": "./src/timeline.tsx",
211
213
  "./loading": "./src/loading.tsx",
212
- "./popover_header": "./src/popover_header.tsx",
213
214
  "./switch_button": "./src/switch_button.tsx",
214
215
  "./alert": "./src/alert.tsx",
215
216
  "./landmark": "./src/landmark.tsx",