@lotics/ui 16.0.0 → 16.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
package/docs/catalog.md CHANGED
@@ -571,7 +571,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
571
571
  select.
572
572
  - **`form_date_picker`** — `FormDatePicker`: `FormField` wrapping a `DatePicker` — one
573
573
  labeled date field. (Omits `style`; for a grid cell use a bare `FormField style={half}`
574
- around `DatePicker`.)
574
+ around `DatePicker`.) Defaults the picker's `accessibilityLabel` to its own visible label
575
+ — the FormField label text is not otherwise associated with the segment inputs. A bare
576
+ `DatePicker` outside a FormField must be given `accessibilityLabel` explicitly; range
577
+ formats suffix each half with the locale's start/end-date names.
575
578
  - **`switch_button`** — `SwitchButton`: the full-ROW toggle — a `PressableHighlight` row
576
579
  (optional icon + medium title left, `Switch` pinned right) where the whole row IS the
577
580
  switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
@@ -605,13 +608,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
605
608
  ### Inline editing & record surfaces
606
609
 
607
610
  - **`inline_edit`** — `useInlineEdit` + `InlineEditView` / `InlineEditFrame` +
608
- `INLINE_CONTROL_HEIGHT` (40) + `inlineValueTextStyle`: the engine custom inline editors
611
+ `useInlineEditFocusRestore` (returns focus to the RESTING control when a KEYBOARD close drops
612
+ it to `<body>` — what an editor with its own resting shape, e.g. the checklist `note` line,
613
+ reaches for instead of the frame) + `INLINE_CONTROL_HEIGHT` (40) +
614
+ `inlineValueTextStyle`: the engine custom inline editors
609
615
  join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
610
616
  INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
611
617
  `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
612
618
  focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
613
619
  does; **`variant: "form" | "cell"`** is THE axis that separates a form field from a data-grid
614
- cell (see below).
620
+ cell (see below). Every commit registers in **`pending_commits`**, which
621
+ `Button`/`IconButton` wait on via **`use_gated_press`** so an action pressed in the same
622
+ gesture as the blur cannot read the record before the edit lands (data_entry.md § Inline
623
+ edit) — automatic, nothing to pass.
615
624
  - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
616
625
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
617
626
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
@@ -654,7 +663,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
654
663
  label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column
655
664
  FILLS the row so a stack of inline editors all span the same width + none jumps wider on
656
665
  edit; optional `trailing` slot renders a right-side action/badge after the value (units
657
- belong IN the value via `InlineNumberInput format`). The FIELD-ANNOTATION vocabulary (same
666
+ belong IN the value via `InlineNumberInput format`). The LABEL WRAPS inside its column and
667
+ is never clipped — a fixed `labelWidth` would otherwise ellipsize every long field name
668
+ ("Registered business address"), and a name the reader can't finish is worse than a taller
669
+ row; a wrapped label's FIRST line stays level with the value's first control line while a
670
+ one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
658
671
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
659
672
  **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
660
673
  mirrors the form order label · description · control); **`warning`** = a consequence to weigh
@@ -738,8 +751,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
738
751
  filled check when done, distinct from the square checkbox; the task/to-do/checklist
739
752
  toggle.
740
753
  - **`checklist`** — `Checklist` + `ChecklistRow` — the record-scoped checklist COMPOUND: it
741
- owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `meta`/
742
- `expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
754
+ owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `note`/`meta`/
755
+ `subtasks`/`expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
743
756
  stays composed — `control` takes the `CheckCircle` (omit onChange = read-only ring; a
744
757
  PICKER list puts a `CheckboxInput` here and sets `controlWidth={24}` so the indent
745
758
  tracks), children the struck transparent `InlineTextInput` or plain `Text`, `trailing` an
@@ -748,8 +761,34 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
748
761
  danger-styled and last, never a bare ✕ (omit on read-only rows). NARROW surfaces (a
749
762
  drawer/peek checklist) put the editors on the `meta` line instead of `trailing` — the
750
763
  second line indents to the title's text edge so the TITLE keeps the full width; wide
751
- surfaces use `trailing`; never both. `expansion` is a BLOCK slot under the row on the
752
- same indent — a transient inline fill editor, a drill-down rendered only while open
764
+ surfaces use `trailing`; never both. **`note` is the row's own free-text line**
765
+ `{value, onSave, addLabel?, editLabel?, placeholder?}` (`ChecklistRowNote`): the note
766
+ renders as a muted line directly under the title (above `meta`, on its text edge) and is
767
+ ALWAYS visible — never behind `expansion` — presses to edit in place, and saving EMPTY
768
+ removes it (`onSave("")`, whitespace trimmed first — clearing IS the delete, so a note
769
+ needs no ⋯ entry). A row with no note shows a small `sticky-note` add button instead,
770
+ which opens the focused empty editor. It composes with `trailing` AND `meta` (a second
771
+ LINE, not a replacement), the save may be async (spinner + inline error, editor stays
772
+ open), and chrome comes from the `checklist` locale slice (`addNote`, `editNote(note)` —
773
+ it wraps the text so the line announces with it — `notePlaceholder`). Never hand-roll a
774
+ muted sub-line + editor on a checklist row. **`subtasks` are the row's collapsible CHILD
775
+ STEPS** — `{items, expanded?, onToggleExpanded?, countLabel?}` (`ChecklistRowSubtasks`),
776
+ each item a `ChecklistSubtask` `{key, label, checked, onToggle}` and nothing more (no
777
+ note, no assignee, no ⋯: a step needing those is a TASK — give it a row). The parent grows
778
+ a chevron expander on the TRAILING side of the title (never the leading edge — that's
779
+ `control`, and a chevron there would knock the title off the text edge on every row
780
+ WITHOUT steps) carrying the count of UNCHECKED steps beside it; expanded, the children
781
+ render as compact `CheckboxInput` + label rows on the title's text edge, denser than the
782
+ parent (24 vs 32) and struck+muted when checked. `items: []` renders NOTHING, not even the
783
+ expander, so a row can pass the prop unconditionally. Expansion is controlled with
784
+ `expanded` + `onToggleExpanded`, or UNCONTROLLED (omit `expanded`): open on mount while any
785
+ step is unchecked, then the user's toggle wins — checking the last step must not yank the
786
+ list shut. A11y: the expander keeps ONE stable name (`countLabel(missing)` → the
787
+ `checklist` locale slice's `subtasks(missing)` → English) with `aria-expanded` carrying the
788
+ state, and the children sit in a `role="group"` named by the row's own title, so they are
789
+ announced UNDER their parent instead of as loose checkboxes. Never hand-roll indented child
790
+ rows with a bespoke chevron. `expansion` is a BLOCK slot under the row on
791
+ the same indent — a transient inline fill editor, a drill-down — rendered only while open
753
792
  (`tpl_record`'s Document set rows are the worked example). SUGGESTIONS are never rows:
754
793
  offer the commons as `SuggestionChip`s under the list. Close the list with `CaptureRow`.
755
794
  There is deliberately NO monolithic Task component — richer task-management rows compose
@@ -440,7 +440,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
440
440
  fact or consequence, never widget mechanics, never a second sentence; `warning` marks a
441
441
  consequence, not decoration. Rows share ONE
442
442
  alignment law: the row top-aligns and label · control · trailing each center within the
443
- first control line — a tall value block never drags the label. A FLAT value row
443
+ first control line — a tall value block never drags the label, and a LABEL too long for its
444
+ column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
445
+ the value. A FLAT value row
444
446
  (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
445
447
  control band's slack so the perceived gap under the text equals the gap under a chip —
446
448
  the band's invisible bottom half must never read as a hole above the description.
@@ -49,6 +49,31 @@ chip, hover reveals the input-family border (no extra grey wash, no pencil icon
49
49
  layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
50
50
  point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
51
51
 
52
+ **Commit-on-blur would race the press that caused it — the kit closes that race for you.**
53
+ Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
54
+ field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
55
+ RE-READS the record — to print a document, mint an invoice, copy the row into a new one — would
56
+ overtake the still-in-flight write and act on the PRE-EDIT value, silently: the new value on
57
+ screen, the old one in the output. So **`useInlineEdit` registers every commit and
58
+ `Button`/`IconButton` hold their press until the commits already in flight have settled**
59
+ (`pending_commits.ts`), showing their busy spinner while they wait. Nothing to wire up: with no
60
+ commit pending the press dispatches synchronously exactly as before, and a FAILED commit
61
+ releases the gate too (the field surfaces its own error). A commit started after the press was
62
+ taken is deliberately not waited for — the gate holds a press for the edit that caused it, never
63
+ for later work.
64
+
65
+ **What the gate cannot see — an action must take an ID, not a captured object.** Holding the
66
+ press makes the STORE current; it cannot refresh a value the handler already closed over.
67
+ `onPress={() => onIssue(invoice)}` captures the invoice as it was at press time, so the
68
+ confirm dialog quotes — and the mint bills — the pre-edit total no matter how long the gate
69
+ waits. Pass the KEY and resolve the object where it is used
70
+ (`onPress={() => onIssue(invoice.key)}`, then `invoices.find(…)` at render): worked in
71
+ `tpl_record`'s Billing band, whose charge cells are inline editors and whose Issue is
72
+ irreversible. The same rule kills the variant with no race at all — a handler reading a server
73
+ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read must not overtake
74
+ an in-flight write) — as does a write started by something other than an inline commit (a
75
+ dialog save, a debounced autosave).
76
+
52
77
  ### Keyboard entry — type → Tab → type
53
78
 
54
79
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
@@ -132,7 +157,9 @@ narrower than its neighbours'. Rows share ONE alignment law: the row top-aligns
132
157
  control · trailing each center within the first control line — annotations (the three tones
133
158
  under the value: `description` muted · `warning` amber · `error` danger, each indented to the
134
159
  chip's 8px text inset) grow the row downward without dragging the label. The full annotation
135
- vocabulary lives in composition.md §Field annotations.
160
+ vocabulary lives in composition.md §Field annotations. A long LABEL wraps inside its column
161
+ instead of clipping — never shorten a field name to fit the label width, and never reach for a
162
+ tooltip to recover the tail; the wrapped label's first line stays level with the value.
136
163
 
137
164
  ### The editability affordance
138
165
 
@@ -236,7 +263,19 @@ struck transparent `InlineTextInput`, or plain `Text`), then EITHER a `trailing`
236
263
  surfaces (the per-task assignee `InlineMemberSelect`, sized by the list's `trailingWidth`) OR a
237
264
  `meta` line under the title on NARROW surfaces (a drawer/popover checklist — the two are mutually
238
265
  exclusive), plus a `menu` for the row's ⋯ options — never a decorative progress strip. `CaptureRow`
239
- closes the list as its add-affordance. A NEW record starts with an EMPTY checklist tasks truly
266
+ closes the list as its add-affordance. A task's own free text the "why it's stuck" nothing else on
267
+ the record holds — is the row's **`note`** (`{value, onSave}`): a muted line under the title, ALWAYS
268
+ visible (a note behind an expansion gets written twice), pressed to edit in place, and REMOVED by
269
+ saving it empty — so it needs no ⋯ entry and no second field. A note-less row carries a small add
270
+ button that opens the focused editor. It composes with `trailing` AND `meta` — a second LINE, not a
271
+ replacement — so a per-row note is never a hand-rolled sub-line, a comment thread, or an
272
+ expansion. A task made of several ticks takes **`subtasks`** (`{items, expanded?,
273
+ onToggleExpanded?, countLabel?}`) rather than N sibling rows: the parent grows a chevron + the
274
+ count of unchecked steps, and the children — `{key, label, checked, onToggle}` and NOTHING else —
275
+ render indented to the title's text edge when open. The line is sharp: a step that needs its own
276
+ note, due date, assignee, or ⋯ menu is a TASK, so give it a row; subtasks are for the ticks that
277
+ only make sense under their parent. Omit `expanded` and the row opens itself while work is left,
278
+ then hands the state to the user. A NEW record starts with an EMPTY checklist — tasks truly
240
279
  vary. The commons split in two: MANDATORY tasks are seeded by the app (a workflow on create, per
241
280
  record type) — no human types them; common-but-OPTIONAL tasks appear as `SuggestionChip`s under
242
281
  the list — a PILL, never a row, so a suggestion can't be mistaken for a task (tap = materialize,
package/docs/templates.md CHANGED
@@ -292,7 +292,12 @@ billing, and quick-capture templates. Top → bottom:
292
292
  = `dueTone` — red past due, amber ≤3d, else muted), the assignee an **`avatarOnly`** bare avatar
293
293
  (dashed add-ghost when unset), sized to the list's `trailingWidth` so the title never overlaps.
294
294
  (Contrast the General section's dates/assignee, the same editors in the default `variant="form"`
295
- — a zinc-50 chip + hover-border + the full member chip.)
295
+ — a zinc-50 chip + hover-border + the full member chip.) Two rows carry a **`note`** — the kit's
296
+ per-row muted second line (press to edit, save empty to remove); the rest show its add button.
297
+ ONE row ("Book the carrier") carries **`subtasks`** — four child ticks behind the kit's chevron +
298
+ remaining-count expander, open because work is left; every other row passes an EMPTY list, which
299
+ renders nothing at all. It is the line to copy: a booking's steps carry only a tick, so they stay
300
+ child rows — anything needing its own note/due/assignee would be a task instead.
296
301
  - **Documents** — the Agents "Document desk" pattern (this template is its worked example —
297
302
  see the Agents chapter below): the register `Table` (search · Add files) whose selection
298
303
  feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
@@ -329,7 +334,10 @@ billing, and quick-capture templates. Top → bottom:
329
334
  scope, once — a band-local problem (charged line missing its method) in the band, a broken
330
335
  record premise (no customer / invalid tax ID) at the section top; self-evident empties
331
336
  stay silent, and NEVER prose beside a CTA. Issuing gates on record premises only — never
332
- silently on stage.
337
+ silently on stage. It is also the worked example of **an irreversible action taking an ID,
338
+ not a captured object**: `onIssue(inv.key)` with the invoice resolved where the confirm
339
+ renders, so the quoted total is the one that will be billed even when the press was held
340
+ for a charge cell's write (data_entry.md § Inline edit).
333
341
  - **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
334
342
  law worked on the record surface: the top is intake, the bottom produces on demand).
335
343
  Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
4
4
  import { Text } from "@lotics/ui/text";
5
- import { colors, type ColorName } from "@lotics/ui/colors";
5
+ import { colors, solid, type ColorName } from "@lotics/ui/colors";
6
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
7
7
  import { Alert } from "@lotics/ui/alert";
8
8
  import { Badge } from "@lotics/ui/badge";
@@ -1391,12 +1391,12 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1391
1391
  {[...landed].sort((a, b) => Number(b.refFlagged) - Number(a.refFlagged)).map((l) => (
1392
1392
  <ListItem
1393
1393
  key={l.id}
1394
- left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? colors.amber[600] : colors.green[600]} />}
1394
+ left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? solid("amber") : solid("green")} />}
1395
1395
  title={`Created ${l.title}`}
1396
1396
  description={l.detail}
1397
1397
  right={
1398
1398
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1399
- {l.refFlagged ? <Text size="xs" weight="medium" style={{ color: colors.amber[600] }}>1 to check</Text> : null}
1399
+ {l.refFlagged ? <Text size="xs" weight="medium" style={{ color: solid("amber") }}>1 to check</Text> : null}
1400
1400
  <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
1401
1401
  </View>
1402
1402
  }
@@ -165,6 +165,16 @@ interface StageTask {
165
165
  /** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
166
166
  * no calendar glyph, coloured by urgency (`tone`), press to edit. */
167
167
  due: string;
168
+ /** The task's own note (`""` = none). The `ChecklistRow` `note` contract owns
169
+ * the whole affordance — the muted second line, the tap-to-edit, the add
170
+ * button on a note-less row, and removal by saving empty. */
171
+ note: string;
172
+ /** The task's CHILD STEPS (`[]` = none, and then nothing renders — not even
173
+ * the expander). The `ChecklistRow` `subtasks` contract owns the whole
174
+ * affordance: the chevron + remaining count on the parent, the open/closed
175
+ * state, and the indented child rows. A step that needs its own note, due
176
+ * date, or assignee is a TASK — give it a row, not a subtask. */
177
+ subtasks: { key: string; label: string; done: boolean }[];
168
178
  }
169
179
 
170
180
  // A due date N days from today — keeps the seeded urgency states (overdue / soon /
@@ -203,13 +213,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
203
213
 
204
214
  // The in-flight demo record's existing checklist (a NEW record gets []).
205
215
  const TASK_SEEDS: StageTask[] = [
206
- { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
207
- { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
208
- { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2) },
209
- { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1) },
210
- { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6) },
211
- { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "" },
212
- { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14) },
216
+ { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), note: "", subtasks: [] },
217
+ { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", note: "", subtasks: [] },
218
+ // two seeded NOTES the "why it's stuck" a task carries and nothing else
219
+ // holds; every other row shows the add affordance instead
220
+ { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), note: "Their finance desk answers after 15:00 only.", subtasks: [] },
221
+ // the one task with SUBTASKS a booking is a sequence, and its steps carry
222
+ // nothing but a tick, so they stay child rows instead of four more tasks
223
+ // (the row opens with work left; press the chevron to close it)
224
+ { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), note: "", subtasks: [
225
+ { key: "s1", label: "Confirm the pickup window", done: true },
226
+ { key: "s2", label: "Send the packing list", done: false },
227
+ { key: "s3", label: "Get the booking reference", done: false },
228
+ { key: "s4", label: "Share the reference with the customer", done: false },
229
+ ] },
230
+ { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), note: "Waiting on the signed copy from the yard.", subtasks: [] },
231
+ { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", note: "", subtasks: [] },
232
+ { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), note: "", subtasks: [] },
213
233
  ];
214
234
 
215
235
  // The assignable roster — an app feeds `useMembers()` here.
@@ -345,7 +365,10 @@ function InvoiceBand({
345
365
  gated: boolean;
346
366
  onAmount: (chKey: string, v: number) => Promise<void>;
347
367
  onMethod: (chKey: string, m: Method) => Promise<void>;
348
- onIssue: (inv: Invoice) => void;
368
+ /** Takes the invoice's KEY, never the `inv` object: the press may be held while
369
+ * a charge cell finishes writing (Button waits out an inline commit), and a
370
+ * captured object would still be the pre-edit snapshot. Resolve at use. */
371
+ onIssue: (invKey: string) => void;
349
372
  }) {
350
373
  const total = invoiceTotal(inv);
351
374
  const state = invoiceStatus(inv);
@@ -410,10 +433,10 @@ function InvoiceBand({
410
433
  {state === "issued" ? (
411
434
  <>
412
435
  <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
413
- <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
436
+ <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
414
437
  </>
415
438
  ) : (
416
- <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
439
+ <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
417
440
  )}
418
441
  </View>
419
442
  </Subsection>
@@ -832,7 +855,9 @@ export function TplRecord() {
832
855
  // ── billing — the bill-to tax ID comes off the CUSTOMER
833
856
  const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
834
857
  const [deposit, setDeposit] = useState(0);
835
- const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
858
+ // The key, not the invoice: what the dialog shows and issues is derived below, so a
859
+ // charge that landed between the press and the confirm is reflected, never a stale total.
860
+ const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
836
861
  const seq = useRef(414);
837
862
  const taxId = customer?.taxId ?? "";
838
863
  const taxIdValid = TAX_ID_RE.test(taxId);
@@ -1133,11 +1158,11 @@ export function TplRecord() {
1133
1158
  const addTask = () => {
1134
1159
  const label = newTask.trim();
1135
1160
  if (!label) return;
1136
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
1161
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1137
1162
  setNewTask("");
1138
1163
  };
1139
1164
  const addSuggested = (desk: Desk, label: string) =>
1140
- setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
1165
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
1141
1166
  const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
1142
1167
  const moveTask = (tid: string, desk: Desk) =>
1143
1168
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
@@ -1148,6 +1173,15 @@ export function TplRecord() {
1148
1173
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
1149
1174
  const dueTask = (tid: string, due: string) =>
1150
1175
  setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
1176
+ // Saving an EMPTY note is the removal — one write path, no separate delete.
1177
+ const noteTask = (tid: string, note: string) =>
1178
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, note } : t)));
1179
+ // A child step patches by key — the kit owns the expander and the count, the
1180
+ // app owns nothing but the tick.
1181
+ const toggleSubtask = (tid: string, key: string, done: boolean) =>
1182
+ setTasks((prev) =>
1183
+ prev.map((t) => (t.id === tid ? { ...t, subtasks: t.subtasks.map((s) => (s.key === key ? { ...s, done } : s)) } : t)),
1184
+ );
1151
1185
  // The Task-list grammar: a clearable Group-by + filter chips derive the
1152
1186
  // sections; empty groups drop (except desks — the journey stays visible).
1153
1187
  const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
@@ -1238,6 +1272,9 @@ export function TplRecord() {
1238
1272
  }, 350);
1239
1273
  });
1240
1274
 
1275
+ // Resolved from the CURRENT invoices every render — the confirm dialog quotes the
1276
+ // total that will actually be billed, including a charge that landed after the press.
1277
+ const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1241
1278
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1242
1279
  const allMissing = invoices.flatMap(missingMethods);
1243
1280
  const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
@@ -1246,7 +1283,7 @@ export function TplRecord() {
1246
1283
  seq.current += 1;
1247
1284
  const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
1248
1285
  setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
1249
- setConfirmIssue(null);
1286
+ setConfirmIssueKey(null);
1250
1287
  logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
1251
1288
  };
1252
1289
 
@@ -1858,6 +1895,23 @@ export function TplRecord() {
1858
1895
  <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1859
1896
  </View>
1860
1897
  }
1898
+ // the row's own NOTE — the muted second line under the
1899
+ // title (the "why it's stuck" a task carries); the kit
1900
+ // owns the whole affordance, so nothing here hand-rolls
1901
+ // a line + editor. Empty = the add button on the row.
1902
+ note={{ value: t.note, onSave: (v) => noteTask(t.id, v), addLabel: `Add note: ${t.label}` }}
1903
+ // the row's CHILD STEPS — one task made of several
1904
+ // ticks. The kit renders the chevron + remaining count
1905
+ // on the parent and the indented child rows when open;
1906
+ // an EMPTY list renders nothing, so every row can pass
1907
+ // the prop unconditionally. Expansion is left
1908
+ // UNCONTROLLED: a row with work left opens itself.
1909
+ subtasks={{
1910
+ items: t.subtasks.map((s) => ({ key: s.key, label: s.label, checked: s.done, onToggle: (on) => toggleSubtask(t.id, s.key, on) })),
1911
+ // the row name matters once the list is long — the
1912
+ // locale default ("Subtasks: 3 left") can't know it
1913
+ countLabel: (missing) => (missing > 0 ? `Subtasks · ${t.label}: ${missing} left` : `Subtasks · ${t.label}: all done`),
1914
+ }}
1861
1915
  menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1862
1916
  >
1863
1917
  <InlineTextInput
@@ -2124,7 +2178,7 @@ export function TplRecord() {
2124
2178
  gated={customer === null || !taxIdValid}
2125
2179
  onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
2126
2180
  onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
2127
- onIssue={setConfirmIssue}
2181
+ onIssue={setConfirmIssueKey}
2128
2182
  />
2129
2183
  ))}
2130
2184
 
@@ -2585,7 +2639,7 @@ export function TplRecord() {
2585
2639
  </Dialog>
2586
2640
 
2587
2641
  {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
2588
- <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
2642
+ <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
2589
2643
  <DialogHeader>
2590
2644
  <DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
2591
2645
  </DialogHeader>
@@ -2601,7 +2655,7 @@ export function TplRecord() {
2601
2655
  </Callout>
2602
2656
  </View>
2603
2657
  <DialogFooter>
2604
- <Button title="Cancel" color="secondary" onPress={() => setConfirmIssue(null)} />
2658
+ <Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
2605
2659
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
2606
2660
  </DialogFooter>
2607
2661
  </Dialog>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "16.0.0",
3
+ "version": "16.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -124,6 +124,7 @@
124
124
  "./menu_button": "./src/menu_button.tsx",
125
125
  "./menu_list_item": "./src/menu_list_item.tsx",
126
126
  "./pressable_highlight": "./src/pressable_highlight.tsx",
127
+ "./pending_commits": "./src/pending_commits.ts",
127
128
  "./pressable_row": "./src/pressable_row.tsx",
128
129
  "./press_door": "./src/press_door.tsx",
129
130
  "./floating_action_bar": "./src/floating_action_bar.tsx",
@@ -151,6 +152,7 @@
151
152
  "./use_screen_size": "./src/use_screen_size.ts",
152
153
  "./use_auto_grow_height": "./src/use_auto_grow_height.ts",
153
154
  "./use_focus_ring": "./src/use_focus_ring.ts",
155
+ "./use_gated_press": "./src/use_gated_press.ts",
154
156
  "./interaction_modality": "./src/interaction_modality.ts",
155
157
  "./inline_focus": "./src/inline_focus.ts",
156
158
  "./use_hover": "./src/use_hover.ts",
@@ -312,4 +314,4 @@
312
314
  "unified": "^11.0.5",
313
315
  "vite": "^7.2.4"
314
316
  }
315
- }
317
+ }
package/src/button.tsx CHANGED
@@ -12,11 +12,12 @@ import { Text } from "./text";
12
12
  import { colors } from "./colors";
13
13
  import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
14
14
  import { Icon, IconName } from "./icon";
15
- import { Ref, useCallback } from "react";
15
+ import { Ref } from "react";
16
16
  import { TextColor } from "./text_utils";
17
17
  import { useTooltip, UseTooltipOptions } from "./tooltip";
18
18
  import { useFocusRing, composeHandler } from "./use_focus_ring";
19
19
  import { getButtonIconColor } from "./button_colors";
20
+ import { useGatedPress } from "./use_gated_press";
20
21
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
21
22
  export type ButtonIconPosition = "left" | "right";
22
23
 
@@ -60,20 +61,18 @@ export function Button(props: ButtonProps) {
60
61
  accessibilityLabel,
61
62
  } = props;
62
63
 
63
- const disabledOrLoading = disabled || loading;
64
+ // The press waits out an inline commit the same gesture started (pending_commits.ts)
65
+ // and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
66
+ // spinner, aria-busy, and the double-press block.
67
+ const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
68
+ const busy = loading || waiting;
69
+ const disabledOrLoading = disabled || busy;
64
70
  const tooltipProps = useTooltip(tooltip);
65
71
  const { focusVisible, focusProps } = useFocusRing();
66
72
 
67
- const handlePress = useCallback(
68
- (event: NativeSyntheticEvent<any>) => {
69
- onPress?.(event);
70
- },
71
- [onPress],
72
- );
73
-
74
73
  const children = (
75
74
  <>
76
- {loading ? (
75
+ {busy ? (
77
76
  <ActivityIndicator color={getButtonIconColor(color)} />
78
77
  ) : (
79
78
  <>
@@ -107,7 +106,7 @@ export function Button(props: ButtonProps) {
107
106
  (typeof tooltip === "string" ? tooltip : tooltip?.text) ||
108
107
  undefined
109
108
  }
110
- aria-disabled={disabledOrLoading || undefined} aria-busy={loading || undefined}
109
+ aria-disabled={disabledOrLoading || undefined} aria-busy={busy || undefined}
111
110
  disabled={disabledOrLoading}
112
111
  style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
113
112
  const isPrimary = color === "primary" && !disabled;