@lotics/ui 7.19.3 → 9.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 (75) hide show
  1. package/AGENTS.md +298 -159
  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_dieline.tsx +3 -3
  7. package/examples/tpl_documents.tsx +790 -0
  8. package/examples/tpl_item_list.tsx +1015 -124
  9. package/examples/tpl_lookup.tsx +37 -10
  10. package/examples/tpl_pick.tsx +3 -3
  11. package/examples/tpl_pivot.tsx +1 -1
  12. package/examples/tpl_record.tsx +1354 -0
  13. package/examples/tpl_report.tsx +7 -7
  14. package/examples/tpl_rollup.tsx +6 -6
  15. package/examples/tpl_shifts.tsx +2 -2
  16. package/examples/tpl_statements.tsx +221 -0
  17. package/examples/tpl_stock.tsx +7 -7
  18. package/examples/tpl_task_board.tsx +48 -32
  19. package/examples/tpl_tasks.tsx +47 -47
  20. package/examples/tpl_tower.tsx +2 -2
  21. package/package.json +9 -9
  22. package/src/agent_run.tsx +1 -1
  23. package/src/capture_row.tsx +59 -0
  24. package/src/change_review.tsx +732 -236
  25. package/src/checklist.tsx +104 -0
  26. package/src/chip.tsx +12 -3
  27. package/src/composer.tsx +22 -1
  28. package/src/confidence.tsx +1 -1
  29. package/src/control_surface.ts +0 -14
  30. package/src/detail_row.tsx +137 -10
  31. package/src/finding.tsx +112 -80
  32. package/src/floating_action_bar.tsx +1 -1
  33. package/src/inline_date_picker.tsx +8 -3
  34. package/src/inline_edit.tsx +40 -10
  35. package/src/inline_member_select.tsx +3 -0
  36. package/src/inline_number_input.tsx +5 -2
  37. package/src/inline_select.tsx +8 -3
  38. package/src/inline_tag_select.tsx +140 -0
  39. package/src/inline_text_input.tsx +5 -2
  40. package/src/inline_time_picker.tsx +5 -2
  41. package/src/ledger.tsx +220 -0
  42. package/src/locale.tsx +43 -16
  43. package/src/progress_bar.tsx +32 -1
  44. package/src/record_summary.tsx +101 -0
  45. package/src/section_heading.tsx +16 -8
  46. package/src/sources.tsx +8 -5
  47. package/src/suggestion_chip.tsx +47 -0
  48. package/src/use_section_nav.test.ts +69 -0
  49. package/src/use_section_nav.ts +59 -0
  50. package/examples/tpl_assistant.tsx +0 -174
  51. package/examples/tpl_billing.tsx +0 -344
  52. package/examples/tpl_briefing.tsx +0 -121
  53. package/examples/tpl_compare.tsx +0 -133
  54. package/examples/tpl_crosscheck.tsx +0 -120
  55. package/examples/tpl_detail.tsx +0 -232
  56. package/examples/tpl_directory.tsx +0 -260
  57. package/examples/tpl_draft.tsx +0 -163
  58. package/examples/tpl_extract.tsx +0 -193
  59. package/examples/tpl_intake.tsx +0 -206
  60. package/examples/tpl_match.tsx +0 -134
  61. package/examples/tpl_order.tsx +0 -482
  62. package/examples/tpl_quick.tsx +0 -211
  63. package/examples/tpl_ratedesk.tsx +0 -386
  64. package/examples/tpl_record_plain.tsx +0 -259
  65. package/examples/tpl_settings.tsx +0 -178
  66. package/examples/tpl_timeline.tsx +0 -244
  67. package/examples/tpl_triage.tsx +0 -112
  68. package/examples/tpl_wizard.tsx +0 -223
  69. package/src/discrepancy.tsx +0 -114
  70. package/src/match_sides.tsx +0 -79
  71. package/src/record_fields.tsx +0 -68
  72. package/src/review_card.tsx +0 -172
  73. package/src/scored_option.tsx +0 -138
  74. package/src/spec_list.tsx +0 -81
  75. package/src/triage_row.tsx +0 -99
@@ -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,10 +25,11 @@ 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";
31
- import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
32
+ import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
32
33
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
33
34
  import { CompletionState } from "@lotics/ui/completion_state";
34
35
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -225,7 +226,7 @@ export function TplTaskBoard() {
225
226
  const [phase, setPhase] = useState<Phase>("idle");
226
227
  const [revealed, setRevealed] = useState(0);
227
228
  const [proposals, setProposals] = useState<Proposal[]>([]);
228
- const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
229
+ const [decisions, setDecisions] = useState<Record<string, ChangeStatus>>({});
229
230
  const [addedCount, setAddedCount] = useState(0);
230
231
 
231
232
  const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
@@ -233,7 +234,7 @@ export function TplTaskBoard() {
233
234
  const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
234
235
  const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
235
236
  setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
236
- const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) =>
237
+ const decide = useCallback((id: string, status: ChangeStatus | null) =>
237
238
  setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
238
239
  const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
239
240
  const addAccepted = useCallback(() => {
@@ -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
 
@@ -371,7 +372,10 @@ export function TplTaskBoard() {
371
372
  </View>
372
373
  </ScrollView>
373
374
 
374
- {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks). */}
375
+ {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks).
376
+ The review provider wraps the WHOLE dialog so the `Change`s (scroll area)
377
+ and the commit bar (`DialogFooter`) share one review context. */}
378
+ <ChangeReview>
375
379
  <Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
376
380
  <DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
377
381
  <DialogScrollArea>
@@ -383,20 +387,29 @@ export function TplTaskBoard() {
383
387
  ) : null}
384
388
  {phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
385
389
  {phase === "review" ? (
386
- <ChangeReview
387
- title="Drafted tasks"
388
- summary={`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}
389
- items={proposals}
390
- getKey={(p) => p.id}
391
- statusOf={(p) => decisions[p.id] ?? "pending"}
392
- renderItem={(p) => <TaskProposal proposal={p} onEdit={editProposal} />}
393
- renderSummary={(p, kept) => (
394
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
395
- )}
396
- onAcceptItem={(i) => decide(proposals[i].id, "accepted")}
397
- onRejectItem={(i) => decide(proposals[i].id, "rejected")}
398
- onUndoItem={(i) => decide(proposals[i].id, null)}
399
- />
390
+ <View style={{ gap: 8 }}>
391
+ <ChangeReviewHeader title="Drafted tasks" />
392
+ <Text size="sm" color="muted">{`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}</Text>
393
+ {proposals.map((p) => {
394
+ const status = decisions[p.id] ?? "pending";
395
+ const kept = status === "accepted";
396
+ return (
397
+ <Change
398
+ key={p.id}
399
+ id={p.id}
400
+ status={status}
401
+ onAccept={() => decide(p.id, "accepted")}
402
+ onReject={() => decide(p.id, "rejected")}
403
+ onUndo={() => decide(p.id, null)}
404
+ >
405
+ <TaskProposal proposal={p} onEdit={editProposal} />
406
+ <ChangeSummary>
407
+ <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
408
+ </ChangeSummary>
409
+ </Change>
410
+ );
411
+ })}
412
+ </View>
400
413
  ) : null}
401
414
  {phase === "done" ? (
402
415
  <CompletionState title={`${addedCount} ${addedCount === 1 ? "task" : "tasks"} added`} summary="They're on the board now." />
@@ -404,7 +417,7 @@ export function TplTaskBoard() {
404
417
  </DialogScrollArea>
405
418
  {phase === "review" ? (
406
419
  <DialogFooter>
407
- <ChangeReviewActions items={proposals} statusOf={(p) => decisions[p.id] ?? "pending"} onAcceptItem={(i) => decide(proposals[i].id, "accepted")} onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
420
+ <ChangeReviewActions onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
408
421
  </DialogFooter>
409
422
  ) : phase === "done" ? (
410
423
  <DialogFooter>
@@ -413,6 +426,7 @@ export function TplTaskBoard() {
413
426
  </DialogFooter>
414
427
  ) : null}
415
428
  </Dialog>
429
+ </ChangeReview>
416
430
  </>
417
431
  );
418
432
  }
@@ -422,12 +436,12 @@ function TaskProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: s
422
436
  <View style={{ gap: 10 }}>
423
437
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
424
438
  <View style={{ flex: 1 }}>
425
- <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" />
426
440
  </View>
427
441
  <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
428
442
  </View>
429
443
  <View style={{ width: 220 }}>
430
- <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" />
431
445
  </View>
432
446
  <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
433
447
  </View>
@@ -514,9 +528,9 @@ function TaskAddRow({ preset, columns, onAdd }: { preset: Partial<Task>; columns
514
528
  // Files / action / ⋯ act on a task that already exists, so the add row leaves
515
529
  // those columns empty — the widths are still reserved so the columns stay aligned.
516
530
  const editor = (key: string) =>
517
- key === "assignee" ? <InlineMemberSelect members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Assignee" accessibilityLabel="Assignee" />
518
- : key === "due" ? <InlineDatePicker value={due} optionalTime onSave={setDue} placeholder="No date" accessibilityLabel="Due date" />
519
- : 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" />
520
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" />
521
535
  : null;
522
536
 
@@ -544,14 +558,16 @@ const styles = StyleSheet.create({
544
558
  addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
545
559
  // Match the inline-editor cells' resting box (INLINE_CONTROL_HEIGHT = 40) so the
546
560
  // files field lines up with the others — and that height affords a larger thumbnail.
547
- clip: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
548
- 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 },
549
565
  clipPreview: { flexDirection: "row", alignItems: "center", gap: 6 },
550
566
  summary: { flexDirection: "row", alignItems: "center", gap: 4 },
551
567
  summaryDot: { width: 8, height: 8, borderRadius: 999 },
552
568
  result: { flexDirection: "row", alignItems: "center", gap: 6, minHeight: 40 },
553
569
  // Fills the cell (height + width) so the link's hit area matches the inline cells.
554
- action: { justifyContent: "center", minHeight: 40, paddingHorizontal: 8, borderRadius: 8, cursor: "pointer" },
555
- 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 },
556
572
  source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
557
573
  });