@lotics/ui 29.2.0 → 30.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.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
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` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus 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
- | [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 — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
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 — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
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). |
22
22
 
@@ -89,6 +89,28 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
89
89
  back — which is what makes scanning twenty records unaffordable. `TableRow`'s `detail` +
90
90
  `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
91
91
  → [catalog.md](./docs/catalog.md).
92
+ - **ONE LEFT EDGE — a row that paints a wash BLEEDS it, and never insets its content.**
93
+ `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal` is net zero, so
94
+ a row's content sits on its container's own edge — level with the headings, labels and prose
95
+ beside it — while the hover/selection wash still breathes by growing into the parent's
96
+ padding. `PressableRow` (all variants), `Table`, `ChoiceList`, `ListItem` and `FileRow`
97
+ already do this, so a screen composed from the kit aligns with **no per-site padding at all**.
98
+ The moment one component insets instead, every neighbour has to be hand-padded by the same
99
+ amount to catch up, and that compensation spreads to each new surface built beside it — which
100
+ is how a `SummaryLine`, a `Clarify` question and a bare table's footer each acquired a
101
+ padding they should never have needed. **An indent is legible only when something VISIBLE
102
+ occupies it** — a checkbox, an ordinal, a diff mark. An empty indent is a defect.
103
+ - **The one exception is a self-contained OVERLAY.** A menu or a selector's popover
104
+ (`MenuListItem`, `OptionList`) has nothing outside itself to align with, so its rows keep
105
+ plain padding. Bleeding there would push rows past the popover's own edge.
106
+ - **A slot holding another component's part takes THAT component's constant**, never a
107
+ hand-picked number that happens to look close: `AgentRun`'s dot column was 18 while the
108
+ `Marker` it holds is `NODE` (24), so a 24px dot centred in an 18px column put the whole run
109
+ rail 3px left of every other band in the dialog. Import the owner's constant.
110
+ - Inside a `Dialog`, every band reads `dialogGutter(small)` — header, scroll area, footer and
111
+ any pane a caller drops in. A hand-copied `small ? 16 : 24` is a fifth copy of a number
112
+ that has to agree with four others.
113
+ → [composition.md](./docs/composition.md).
92
114
  - **Documents get a LIST; images get a GRID.** The discriminator is what identifies the file to
93
115
  the person looking for it. A PDF, a Word doc and a spreadsheet are identified by their NAME —
94
116
  their thumbnails are a grey page with grey lines on it, identical to every other grey page —
package/MIGRATION.md CHANGED
@@ -4,6 +4,29 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 30.0.0 — a washed row bleeds; content sits on the container's edge
8
+
9
+ No API changed. Every register, table and choice list **moves its content 20px
10
+ left** onto its container's own edge, because `PressableRow` (all three
11
+ variants), `ChoiceList` and `Table` now bleed their hover/selection wash outward
12
+ instead of insetting content to make room for it. `ROW_WASH_BLEED` (8) in
13
+ `control_surface` names the rule.
14
+
15
+ **What you may have to remove.** If a surface of yours was hand-padded to line
16
+ up with a register or a table, that compensation is now a double-correction and
17
+ will sit 20px out. The shapes to look for:
18
+
19
+ | Compensator | What to do |
20
+ |---|---|
21
+ | A `SummaryLine`, caption or toolbar wrapped in `paddingHorizontal: 20` to match the rows below it | Drop the padding |
22
+ | `CardFooter` used as a **bare table's** footer (no `Card` around it) | Use a `Divider` + a plain row; `CardFooter` carries a *card's* 20px interior gutter |
23
+ | A heading or question hand-padded 8 to match a `ChoiceList`/`Clarify` block | Drop the padding |
24
+ | Anything reading `ROW_GUTTER` for its own spacing | It is 0 now — it means "the table's content gutter", and the wash no longer lives in it |
25
+
26
+ Tables also fit slightly MORE: the fit math stops reserving 40px of horizontal
27
+ padding, so a column that used to drop at a given width may now survive. Nothing
28
+ that was visible becomes hidden — the change only ever un-drops.
29
+
7
30
  ## 29.0.0 — review surfaces compose from atoms
8
31
 
9
32
  `ChangeReview` and its family are DELETED, replaced by three layout-free pieces. The
@@ -133,6 +133,30 @@ transition — never leave a live button waiting on a network round-trip:
133
133
  `useWorkflow` calls are NOT deduped (rapid distinct calls are legitimate), so a workflow CTA
134
134
  must disable itself (`loading`) while pending or a double-click writes twice.
135
135
 
136
+ ## Stopping — the exit is part of the trigger
137
+
138
+ The press being irreversible is a property of the BUTTON, not of the run. Every streaming surface
139
+ owes the operator a way out, because the reason to leave is usually visible in the first few
140
+ steps: it read the wrong document, it is grouping the wrong way, the brief was wrong. Without an
141
+ exit they pay for a result they already know they will discard.
142
+
143
+ - **`cancel`, never `abort`.** `useAgentRun` exposes both and they are not synonyms. `abort` stops
144
+ LISTENING — the run keeps executing server-side and lands in the session history. `cancel` stops
145
+ the run itself. A user-facing Stop wires to `cancel`; `abort` is for unmount.
146
+ - **Closing a dialog must CANCEL.** This is the one that leaks silently. The hook aborts on
147
+ unmount by design — right for a page you navigate away from and will come back to, wrong for a
148
+ dialog just dismissed, where the result is abandoned and every remaining token is spent on
149
+ nobody. Unmount is not cancellation; the close handler calls `cancel` itself.
150
+ - **Stop lands where the run STARTED**, not on a dead transcript — the intake, with the files, the
151
+ brief and the answers still there, because the operator stopped in order to change one of them.
152
+ It is the same destination the parked wizard's Cancel uses; only the verb differs, and it
153
+ differs truthfully — you STOP something running, you CANCEL something waiting.
154
+ - **The control is PINNED, in the footer.** A feed that follows its newest part scrolls a control
155
+ placed inside it away from the reader — exactly while the run is doing the thing they want to
156
+ stop. `AgentRunActions` renders Stop for you; a surface composing its own footers states it
157
+ directly ([`tpl_item_list`](../examples/tpl_item_list.tsx) and
158
+ [`tpl_record`](../examples/tpl_record.tsx) respectively).
159
+
136
160
  ## Show the work — `AgentRun`
137
161
 
138
162
  `AgentRun` (`@lotics/ui/agent_run`) is a live feed of the agent's work as a TIMELINE. The prop is
@@ -424,8 +448,8 @@ Reach for it whenever an agent run happens inside a dialog — which is nearly a
424
448
  const run = useAgentRun("intake"); // @lotics/app-sdk
425
449
  <AgentRunScope> {/* wraps the Dialog, like ClarifyWizardScope */}
426
450
  <Dialog …>
427
- <AgentRunPane run={run} onCancel={} /> {/* content */}
428
- <DialogFooter><AgentRunActions run={run} /></DialogFooter> {/* actions */}
451
+ <AgentRunPane run={run} onCancel={back} /> {/* content */}
452
+ <DialogFooter><AgentRunActions run={run} onStop={back} /></DialogFooter> {/* actions */}
429
453
  </Dialog>
430
454
  </AgentRunScope>
431
455
  ```
@@ -438,8 +462,15 @@ Submit, leaving a live `awaiting_input` run readable and unanswerable until it e
438
462
  also swaps `FollowScroll` for `DialogScrollArea` on the park, so the question opens at the top
439
463
  rather than wherever the feed was scrolled to.
440
464
 
465
+ **Mount `AgentRunActions` wherever the run drives the surface — never gated on one phase.** It
466
+ holds the wizard's verbs while a question is up, **Stop** while the run streams, and nothing once
467
+ it settles; the RUN decides, which is the whole point of handing it the run. A host that names a
468
+ single phase out here duplicates that decision and gets it wrong the moment the bar learns a new
469
+ one — which is precisely how two streaming phases ended up with an empty footer and no way to
470
+ stop.
471
+
441
472
  **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
442
- `answerChoice`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
473
+ `answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
443
474
  test declares one directly — an option is a label + description and the answer's value IS the
444
475
  label, which is the `ask_user_choice` wire shape, so nothing maps between two question types). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
445
476
  `@lotics/app-sdk` — which also means a template or test can hand it a plain object and exercise the
package/docs/catalog.md CHANGED
@@ -1328,7 +1328,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1328
1328
 
1329
1329
  - **`mime`** — pure MIME predicates: `isImageMimeType` / `isVideoMimeType` /
1330
1330
  `isAudioMimeType` / `isPdfMimeType` / `isExcelMimeType` / `isCsvMimeType` /
1331
- `isDocxMimeType` / `isPreviewableMimeType`.
1331
+ `isDocxMimeType` / `isPreviewableMimeType`. Plus
1332
+ **`resolveMimeType(declaredType, filename)`** — what to TREAT a file as. An
1333
+ honestly-declared type is returned untouched; an *uninformative* one (`""`,
1334
+ `application/octet-stream` and friends — what an OS clipboard or drag hands
1335
+ over) is resolved from the filename extension instead, falling back to the
1336
+ declaration when the extension is unknown too. It returns the same answer the
1337
+ server stores, so a surface that filters on it agrees with what gets persisted.
1332
1338
  - **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
1333
1339
  download that works inside sandboxed iframes (where `window.open` is silently dropped);
1334
1340
  `credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
@@ -122,6 +122,31 @@ without scanning. The heading row is the section's control line — it already c
122
122
  and the view controls — so the add belongs on it, in the same spot whether the section holds
123
123
  nought or forty.
124
124
 
125
+ **Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
126
+ bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
127
+ to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
128
+ can return to, and on a surface with no heading the bar IS that place. `FilesEditorUpload` is for
129
+ exactly this case; inside a section the host puts its own `Button` on the heading instead, because
130
+ `FilesEditor` provides the context and the heading is outside it — and a file widget rendering a
131
+ `SectionHeading` would own something the section owns. Worked both ways in `tpl_record` (Files,
132
+ Photos) and `tpl_task_board` (the attachment popover).
133
+
134
+ **A header CHECKBOX is legible only when it heads a COLUMN.** A table's select-all works because
135
+ it sits at the same x as every row's box beneath it — the column is what turns a 24px tick into
136
+ "all of these". A grid has no such column, so the same control in a section heading heads
137
+ nothing: it is a transparent 24×24 at radius 6 standing between 40px grounded buttons at radius
138
+ 10, off their baseline, differing on every axis at once, and shape is read before meaning. **A
139
+ grid's select-all is a BUTTON** ("Select all" / "Deselect all"), which is what
140
+ `FilesEditorSelectAll` ships. Nothing is lost by giving up the tri-state: `indeterminate` says
141
+ "some", and the `FloatingActionBar` beside it already says WHICH some as an exact count — the
142
+ glyph was approximating a number that was on screen.
143
+
144
+ While a mode is ON, the heading row holds only that mode's controls; the section's own ADD stands
145
+ down until it is off. Otherwise a `primary` add sits in a row that is entirely about the
146
+ selection, competing with the promoted act in the action bar — two accents on one screen. At rest
147
+ the add returns to the right edge where the law puts it, and drag/paste never stopped working, so
148
+ no path actually closes.
149
+
125
150
  - **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
126
151
  `primary`. Don't drop to `secondary` because the list is full — an add that changes weight
127
152
  with row count is the fault, not the cure.
@@ -306,9 +331,50 @@ Divider-separated.
306
331
 
307
332
  | `PressableRow` variant | Shape | Use |
308
333
  |---|---|---|
309
- | `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash; content sits on the 20px gutter so a `Table` header + cells align | THE record list |
310
- | `bleed` (legacy) | 20px square, `Divider`-separated | only an edge-to-edge data grid that genuinely wants hard rules |
311
- | `inset` | rounded (radius 8), pulled in from the edge | grouped lists in a padded container (Accordion drill-downs) |
334
+ | `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash | THE record list |
335
+ | `bleed` (legacy) | square wash, `Divider`-separated | only an edge-to-edge data grid that genuinely wants hard rules |
336
+ | `inset` | rounded (radius 8) | grouped lists in a padded container (Accordion drill-downs) |
337
+
338
+ **A washed row BLEEDS its wash; it never insets its content.** All three variants
339
+ carry `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal`
340
+ — net zero for layout — so a row's content sits on its container's own edge,
341
+ level with the headings, labels and prose around it, while the wash still gets
342
+ breathing room by growing into the parent's padding. The parent must afford
343
+ `ROW_WASH_BLEED` (8px); every kit container clears it (`Drawer` 20, `Dialog` 24,
344
+ the page canvas 28).
345
+
346
+ **A bar of verbs is TWO groups, not a row of peers.** `FloatingActionBar` separates its own
347
+ STATE (the count and Clear — what is picked, and how to un-pick it) from the ACTS
348
+ (`children`) with the section boundary getting the most air (20) and each group sitting on the
349
+ action gap (8). Spaced uniformly, four controls read as four equal things, and the emphasis
350
+ ladder between them — muted for a dismiss, `danger` for a destructive act, `primary` for the
351
+ promoted one — stops reading as a ladder and starts reading as arbitrary variety. The colours
352
+ are not the defect when this happens; the spacing is. Group first, and the ladder is then read
353
+ WITHIN the acts, where it means something.
354
+
355
+ The grouping pays a second time at PHONE width, which is why it is structure and not styling. A
356
+ count plus an escape plus three verbs is ~520px of content — wider than a 375 screen — so the
357
+ bar wraps; because `state` and `acts` are each a nowrap row, the ONLY break available is the
358
+ boundary between them. The line splits exactly where the meaning does (what is picked / what to
359
+ do with it) instead of orphaning one verb from the ladder it belongs to. A row of five peers has
360
+ no such seam and breaks wherever it runs out of room.
361
+
362
+ **Two exceptions, both real.** A self-contained OVERLAY — a menu, a selector's popover
363
+ (`MenuListItem`, `OptionList`) — has nothing outside itself to align with, so its rows keep
364
+ plain padding; bleeding there would push them past the popover's own edge. And a slot that
365
+ holds another component's PART takes that component's constant, never a hand-picked number:
366
+ `AgentRun`'s dot column was 18 while the `Marker` it holds is `NODE` (24), so a 24px dot
367
+ centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
368
+ wrong by the same amount, so it read as internally consistent and nothing flagged it.
369
+
370
+ The register used to pad 20 WITHOUT the negative margin, and the cost was not
371
+ the 20px — it was that everything nearby had to be hand-padded by the same 20 to
372
+ line up with it. A `Table`'s header band, its static rows and its expanded detail
373
+ all inherited the number; a `SummaryLine` above the register got it at the call
374
+ site; a bare table's footer borrowed `CardFooter` and picked up a *card's*
375
+ interior gutter. **An indent is legible only when something VISIBLE occupies it**
376
+ — a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
377
+ compensation it forces spreads to every new surface that sits beside it.
312
378
 
313
379
  The `Divider` BETWEEN rows is the resting separation; the wash is the hover state. `selected`
314
380
  shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
@@ -419,7 +485,7 @@ and the "detail" of a list belongs in a `Drawer`/sheet, not a second column):
419
485
  band, plus a bottom-pinned `<FloatingActionBar count label onClear>` carrying the bulk
420
486
  action(s) while ≥ 1 row is ticked (it renders nothing at count 0); a row that can't take the
421
487
  action has no usable checkbox. Bar weight gradient, left → right: the built-in Clear is muted
422
- (the quietest act), destructive bulk = `danger-secondary` + icon, secondary bulk = `secondary`
488
+ (the quietest act), destructive bulk = `danger` + icon, secondary bulk = `secondary`
423
489
  + icon, ONE primary CTA last — never repeat the count in a CTA title; the bar's label already
424
490
  carries it.
425
491
 
@@ -504,7 +570,16 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
504
570
  action `primary`; it sits at a different altitude than the form's one terminal commit.)
505
571
  - **Button labels carry no trailing ellipsis** ("Assign", not "Assign…"). **Button color is
506
572
  VALENCE/RISK, never category**: the ladder `muted < secondary < primary` is the emphasis axis;
507
- `danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis. A
573
+ `danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis.
574
+ **Which of the two is decided by the COMPANY the button keeps, never by how destructive the
575
+ act is.** Among other BUTTONS — a bar, a form's action row, a toolbar — use `danger`: it
576
+ carries a ground (red-100) exactly where `secondary` carries zinc-100, so every act in the row
577
+ keeps ONE shape and only the ink moves; the ladder is then legible as a ladder. Drop a
578
+ groundless `danger-secondary` in there and the destructive verb is the one control with no box
579
+ — a shape difference, which a reader parses before colour and reads as an accident, and the
580
+ row stops looking designed (this is what "four buttons with different shapes" is, every time).
581
+ Among TEXT — a row's trailing verb, a `Callout`'s action, a dialog's tertiary — use
582
+ `danger-secondary`, because there a filled box is the thing that looks pasted on. A
508
583
  fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
509
584
  its INK, inset by its padding: **never align one to a column of TEXT** — it reads as indented
510
585
  against every label starting on the true edge. The answer is to move the act, not to invent a
@@ -581,6 +581,19 @@ intake paths, all wired to ONE handler:
581
581
  modal dialog with no wrapper). Scope it with `enabled` ("while this record/dialog is open");
582
582
  a paste with no acceptable file is left alone, so Ctrl+V in a text field still pastes text.
583
583
 
584
+ **What `accept` is matched against, and the one thing a paste cannot do.** A pattern is an
585
+ extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME. A file is matched on
586
+ `resolveMimeType(file.type, file.name)` — its own declared type when that type says something,
587
+ and what its EXTENSION says when it doesn't. That fallback is not a nicety: a file copied in
588
+ Finder/Explorer and pasted arrives with real bytes and a real filename but an uninformative
589
+ type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
590
+ of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
591
+ unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
592
+ the resolved type also means the surface agrees with what the server will store. Separately,
593
+ some clipboards carry only a file *reference* (a path/URL, no bytes): there is nothing to
594
+ upload and no API that can go read it, so those pastes cannot work by any means — dragging the
595
+ file is the path that always does.
596
+
584
597
  **Paste routing (focus, then stack).** When two file targets are enabled on the same layer,
585
598
  the paste goes to the TOP-MOST one whose `region` contains focus (`document.activeElement`) —
586
599
  so two peer file sections each win while the user is working in them, never "last-mounted
@@ -7,7 +7,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";
9
9
  import { Button } from "@lotics/ui/button";
10
- import { CardFooter } from "@lotics/ui/card";
11
10
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
12
11
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
13
12
  import { Divider } from "@lotics/ui/divider";
@@ -571,6 +570,11 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
571
570
  );
572
571
  setPhase("running");
573
572
  },
573
+ // A REAL app passes `useAgentRun().cancel` — which stops the run on the
574
+ // SERVER, not just locally. Here the run IS the reveal timer below, and that
575
+ // timer is gated on `phase`, so leaving the streaming phase is what stops
576
+ // it; there is nothing else for this mock to tear down.
577
+ cancel: () => {},
574
578
  };
575
579
  useEffect(() => {
576
580
  if (phase !== "analyze" && phase !== "running") return;
@@ -624,7 +628,17 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
624
628
  setDienThoai("");
625
629
  setPhi(null);
626
630
  };
627
- const close = () => { onOpenChange(false); reset(); };
631
+ /**
632
+ * Closing CANCELS — it does not merely stop watching.
633
+ *
634
+ * `useAgentRun` aborts on unmount, and its `abort` is deliberately
635
+ * local-only: the run keeps executing server-side so it survives navigating
636
+ * away and lands in the session history. That is right for a page you will
637
+ * come back to, and wrong for a dialog you just dismissed — the operator has
638
+ * abandoned the result and every remaining token is spent on nobody. So the
639
+ * close path calls `cancel` explicitly. Unmount is not cancellation.
640
+ */
641
+ const close = () => { run.cancel(); onOpenChange(false); reset(); };
628
642
 
629
643
  /**
630
644
  * THE COMMIT. It writes what is on screen — the edited drafts, minus the
@@ -955,9 +969,22 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
955
969
  <Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
956
970
  <Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
957
971
  </DialogFooter>
958
- ) : phase === "clarify" ? (
972
+ ) : phase === "analyze" || phase === "clarify" || phase === "running" ? (
959
973
  <DialogFooter>
960
- <AgentRunActions run={run} />
974
+ {/* EVERY run-driven phase, not just `clarify`. This branch used to
975
+ name that one phase, because `AgentRunActions` only ever held the
976
+ wizard and there was nothing to show while the feed streamed —
977
+ so the two streaming phases had an empty footer and no way to
978
+ stop. The component's contract is that the HOST mounts it
979
+ wherever the run drives the surface and the RUN decides what it
980
+ holds; gating it by phase out here duplicates that decision and
981
+ gets it wrong the moment the run learns a new one.
982
+
983
+ `onStop` is the SAME handler the pane's `onCancel` gets: stopping
984
+ a streaming run and abandoning a parked question both put the
985
+ operator back at the intake they started from, with their files
986
+ and their answers still there to retry from. */}
987
+ <AgentRunActions run={run} onStop={() => setPhase("intake")} />
961
988
  </DialogFooter>
962
989
  ) : phase === "preview" ? (
963
990
  <DialogFooter>
@@ -1252,7 +1279,13 @@ export function TplItemList() {
1252
1279
  <EmptyState message="No matching records" hint="Try another keyword or switch status" />
1253
1280
  </>
1254
1281
  ) : null}
1255
- <CardFooter>
1282
+ {/* NOT `CardFooter` — that is a Card PART, and its 20px is the card's
1283
+ interior gutter. There is no card here, so it put the totals 20px
1284
+ right of the register's own rows and of the toolbar above them.
1285
+ A bare table's footer is a divider and a row; borrowing furniture
1286
+ from a container that is not there is what put it out of true. */}
1287
+ <Divider />
1288
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12 }}>
1256
1289
  <Text size="sm" color="muted" tabular style={{ flex: 1 }}>
1257
1290
  {`${filtered.length} records, ${formatMoney(tongPhi)} in fees`}
1258
1291
  </Text>
@@ -1264,7 +1297,7 @@ export function TplItemList() {
1264
1297
  total={filtered.length}
1265
1298
  onPageChange={setPage}
1266
1299
  />
1267
- </CardFooter>
1300
+ </View>
1268
1301
  </View>
1269
1302
  </View>
1270
1303
  </ScrollView>