@lotics/ui 29.3.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
@@ -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
@@ -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>
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
1
+ import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
@@ -45,6 +45,7 @@ import { EmptyState } from "@lotics/ui/empty_state";
45
45
  import { FormField } from "@lotics/ui/form_field";
46
46
  import { TextInputField } from "@lotics/ui/text_input_field";
47
47
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
48
+ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
48
49
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
49
50
  import { DangerZone } from "@lotics/ui/danger_zone";
50
51
  import { FileRow } from "@lotics/ui/file_row";
@@ -423,6 +424,18 @@ const DOCS: Doc[] = [
423
424
  // an IMAGE file — the register renders it as a real square thumbnail (the
424
425
  // SVG data URI stands in for the stored photo URL a live app serves)
425
426
  { id: "f5", name: "delivery-photo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1424, added: "30 Jun", addedAt: 630, url: MOCK_PHOTO_URL },
427
+ // The hand-over set. EIGHT photos, not one: a grid with a single tile does not
428
+ // exercise a grid — it never wraps, its tiles are never told apart by content,
429
+ // and a Select mode over one item reads as absurd. A photo set on a real
430
+ // consignment is a dozen frames of the same yard from different angles, which
431
+ // is the case the surface has to survive.
432
+ { id: "p1", name: "hạ-container-01.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 820, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNkYzI2MjYnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
433
+ { id: "p2", name: "hạ-container-02.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 957, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZDdhYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3ODcxNmMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMDg5MWIyJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxNTVlNzUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwODkxYjInIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
434
+ { id: "p3", name: "seal-truoc-khi-keo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1094, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JiZjdkMCcvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1NzUzNGUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYjQ1MzA5Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3YzJkMTInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiNDUzMDknIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
435
+ { id: "p4", name: "cont-rong-mat-truoc.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1231, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2U5ZDVmZicvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhMWExYWEnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYmUxMjNjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTI5M2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiZTEyM2MnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
436
+ { id: "p5", name: "cont-rong-ben-trong.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1368, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlY2FjYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM0NDQwM2MnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMGY3NjZlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMzMzQxNTUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwZjc2NmUnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
437
+ { id: "p6", name: "bien-so-xe.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1505, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZjA4YScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1MjUyNTInLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjN2UyMmNlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTNhOGEnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3ZTIyY2UnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
438
+ { id: "p7", name: "bang-ke-giao-nhan.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1642, added: "02 Jul", addedAt: 702, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2E1ZjNmYycvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3MzczNzMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYzI0MTBjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwNjRlM2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNjMjQxMGMnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
426
439
  ];
427
440
 
428
441
  const EXTRACT_STEPS: ScriptStep[] = [
@@ -1269,7 +1282,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1269
1282
  // ── the document desk (see the module banner above; the record's files ARE
1270
1283
  // the desk's register)
1271
1284
  const [files, setFiles] = useState<Doc[]>(DOCS);
1285
+ // The photo subset of the SAME collection — one list, two surfaces. Derived
1286
+ // rather than a second state, or the two sections would drift the moment
1287
+ // anything is added to one of them.
1288
+ const photoDocs = useMemo(() => files.filter((f) => f.mimeType.startsWith("image/")), [files]);
1289
+ /**
1290
+ * SELECT MODE — for the grid only, and only because a grid has nowhere free
1291
+ * to put a checkbox.
1292
+ *
1293
+ * The Files LIST leaves its checkboxes on permanently: a table already has a
1294
+ * leading column, so they cost nothing and selection is always one tap away.
1295
+ * A photo tile has no such column — a permanent checkbox sits ON the content,
1296
+ * and worse it splits a 96px tile into two hit targets (tap the tile to open,
1297
+ * tap the box to select) exactly where fingers are least precise. So the grid
1298
+ * gets a mode instead: the default view is photos, and one visible control
1299
+ * switches it to picking. Hover-to-reveal was the other candidate and it is
1300
+ * not a design — it simply does not exist on touch, and long-press is a
1301
+ * gesture nothing on screen advertises.
1302
+ *
1303
+ * The selection itself is SHARED with the Files table rather than a second
1304
+ * one, because these are the same records seen two ways. Two selections would
1305
+ * mean two `FloatingActionBar`s racing for the same corner.
1306
+ */
1307
+ const [pickingPhotos, setPickingPhotos] = useState(false);
1272
1308
  const sel = useSelection();
1309
+ const photoIds = useMemo(() => photoDocs.map((p) => p.id), [photoDocs]);
1310
+ const allPhotosPicked = sel.allSelected(photoIds);
1273
1311
 
1274
1312
  // Use AI — one entry off the selection, forking into the two document tasks.
1275
1313
  const [aiOpen, setAiOpen] = useState(false);
@@ -1321,6 +1359,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1321
1359
  };
1322
1360
  // The upload path's save: pending picked files land on the record only here.
1323
1361
  const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
1362
+ /**
1363
+ * STOP the run and go back to the intake — the files, the brief and the task
1364
+ * choice are all still there, because the operator stopped to change one of
1365
+ * them. A real app cancels the run itself first:
1366
+ * run.cancel(); // `useAgentRun().cancel` — stops it SERVER-side
1367
+ * Here the run is the reveal timer below, which is gated on `phase`, so
1368
+ * leaving "running" is what stops it.
1369
+ */
1370
+ const stopRun = () => { setPhase("fork"); setRevealed(0); setTask(null); };
1371
+ /**
1372
+ * Closing CANCELS the run — it does not merely stop watching it.
1373
+ *
1374
+ * `useAgentRun` aborts on unmount, and its `abort` is local-only BY DESIGN:
1375
+ * the run keeps executing server-side so it survives navigating away and
1376
+ * lands in the session history. Right for a page you will return to, wrong
1377
+ * for a dialog just dismissed — the result has been abandoned and every
1378
+ * remaining token is spent on nobody. So a real app calls `run.cancel()`
1379
+ * here. Unmount is not cancellation.
1380
+ */
1324
1381
  const closeAi = () => {
1325
1382
  setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
1326
1383
  setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); lines.reset(); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); fields.reset();
@@ -1810,6 +1867,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1810
1867
  // there.
1811
1868
  { key: "comments", label: "Comments", icon: "message-square" },
1812
1869
  { key: "files", label: "Files", icon: "folder-closed" },
1870
+ { key: "photos", label: "Photos", icon: "image" },
1813
1871
  { key: "transport", label: "Transport", icon: "map-pin" },
1814
1872
  { key: "fees", label: "Fees", icon: "receipt" },
1815
1873
  { key: "billing", label: "Billing", icon: "credit-card" },
@@ -1817,9 +1875,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1817
1875
  { key: "receipt", label: "Delivery receipt", icon: "log-in" },
1818
1876
  { key: "danger", label: "Danger zone", icon: "circle-alert" },
1819
1877
  ] as const;
1820
- // Kept in step with `SECTIONS` above by hand a key added there and forgotten
1821
- // here type-errors at every `nav.register` call, which is the cheap failure.
1822
- const nav = useSectionNav(["progress", "general", "comments", "files", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
1878
+ // The explicit `<SecKey>` is what keeps this list and `SECTIONS` in step, and
1879
+ // it is not decoration. Inferred, this tuple defines its own key type and is
1880
+ // checked against nothing so a section registered and scrolled to, but never
1881
+ // added to `SECTIONS`, compiles clean and simply has no rail row. That is the
1882
+ // silent direction, and it is the one that happened: Photos shipped reachable
1883
+ // by scroll and invisible in the outline. Bound to `SecKey`, a key here that
1884
+ // `SECTIONS` does not carry stops being assignable, so the omission is a
1885
+ // compile error instead of a missing row nobody notices.
1886
+ const nav = useSectionNav<SecKey>(["progress", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
1823
1887
  // ONE record, ONE page. A section is a place you SCROLL to, never a
1824
1888
  // destination you swap to: routing a record was tried here and lost, because
1825
1889
  // every fix it needed rebuilt the whole-record view in miniature — a dot to
@@ -2613,6 +2677,111 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2613
2677
  </Section>
2614
2678
  </View>
2615
2679
 
2680
+ {/* PHOTOS — the same collection, the other SURFACE.
2681
+
2682
+ Files above is a row LIST because a shipping document is identified by
2683
+ its NAME: a PDF thumbnail is a grey page identical to every other grey
2684
+ page, so a grid of them is a wall of one tile. A photo is identified
2685
+ by its CONTENT — `IMG_4471.jpg` tells nobody anything — so the tile IS
2686
+ the identity and a grid puts twelve on screen at once. Same record,
2687
+ same verbs, different surface, decided by what the reader recognises.
2688
+ (AGENTS.md: documents get a LIST, images get a GRID.)
2689
+
2690
+ The ADD rides the heading row, right edge, exactly as it does on Files
2691
+ and Fees — a section's add sits in the same spot whether the section
2692
+ holds nought or forty. Note it is a plain `Button` + `pickFiles`, not
2693
+ `FilesEditorUpload`: that verb reads the `FilesEditor` context, and the
2694
+ heading is OUTSIDE the editor by construction, so a compound's own
2695
+ upload button can never reach the one place the law puts it. */}
2696
+ <View onLayout={nav.register("photos")}>
2697
+ <Section>
2698
+ <SectionHeading>
2699
+ <SectionHeadingTitle description="Condition and hand-over photos. Drag, paste, or click to add.">Photos</SectionHeadingTitle>
2700
+ {/* Secondary LEFT of primary — the same order every action row in the
2701
+ system uses, so the section's add stays on the right edge where
2702
+ the add-placement law puts it and the mode toggle never displaces
2703
+ it. In mode, the toggle becomes the way OUT; the bulk verbs never
2704
+ come here, because a verb that needs a selection has no meaning
2705
+ until there is one. */}
2706
+ {/* GROUPED, so the heading's 10px slot gap separates the TITLE from
2707
+ the pair rather than the two buttons from each other. A pair of
2708
+ buttons is an action group and takes the action gap (8) every
2709
+ other action row in the kit uses — a Dialog footer, a comment
2710
+ composer, the wizard. Wrapping is composition, not the per-site
2711
+ padding this kit spent the day deleting: it states that these two
2712
+ belong together, rather than compensating for someone else's
2713
+ geometry. */}
2714
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
2715
+ {/* SELECT ALL, in mode only — the same control the Files table
2716
+ carries in its header, at the grid's equivalent surface. A grid
2717
+ has no header row to head its tiles, so the section heading is
2718
+ where it goes; without it, picking 40 photos is 40 taps while
2719
+ the list beside it does the job in one. Reads all/some/none
2720
+ from the SAME `sel` the table uses, so the two sections cannot
2721
+ disagree about what is picked. */}
2722
+ {/* SELECT ALL is a BUTTON here, not the checkbox the Files table
2723
+ header carries, and the difference is not a preference. A bare
2724
+ header checkbox is legible only when it HEADS A COLUMN — the
2725
+ table's sits at the same x as every row's box beneath it, which
2726
+ is what makes a 24px tick in a header row read as "all of
2727
+ these". A grid has no such column, so the same control in a
2728
+ heading row heads nothing: a 24×24 transparent 6px-radius tick
2729
+ standing between two 40px grounded 10px-radius buttons, off
2730
+ their baseline by 8px, differing on every axis at once. Shape
2731
+ is read before meaning, so it read as a stray.
2732
+ Nothing is lost by dropping the tri-state: `indeterminate` said
2733
+ "some", and the action bar already says WHICH some, as an exact
2734
+ count. A glyph was approximating a number that was on screen. */}
2735
+ {pickingPhotos ? (
2736
+ <Button
2737
+ title={allPhotosPicked ? "Deselect all" : "Select all"}
2738
+ color="secondary"
2739
+ onPress={() => sel.setAll(photoIds, !allPhotosPicked)}
2740
+ />
2741
+ ) : null}
2742
+ <Button
2743
+ title={pickingPhotos ? "Done" : "Select"}
2744
+ color="secondary"
2745
+ onPress={() => {
2746
+ if (pickingPhotos) sel.setAll(photoIds, false);
2747
+ setPickingPhotos(!pickingPhotos);
2748
+ }}
2749
+ />
2750
+ {/* The ADD stands down while picking. Not decluttering for its own
2751
+ sake: in select mode this row is ABOUT the selection, and the
2752
+ section's own act is a third kind of thing in a two-control
2753
+ row — which is how the primary ended up competing with the one
2754
+ in the action bar (`Use AI`), two accents on one screen. At
2755
+ rest the add is back on the right edge where the placement law
2756
+ puts it, and while picking, drag/paste still adds, so the path
2757
+ is never actually closed. */}
2758
+ {pickingPhotos ? null : (
2759
+ <Button
2760
+ title="Add photos"
2761
+ color="primary"
2762
+ onPress={() => {
2763
+ void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
2764
+ }}
2765
+ />
2766
+ )}
2767
+ </View>
2768
+ </SectionHeading>
2769
+ <FileThumbnailGrid
2770
+ files={photoDocs.map(toDisplay)}
2771
+ minItemWidth={104}
2772
+ // Checkboxes exist only in mode — `selectedIds` undefined is the
2773
+ // clean preview. ONE meaning per tap: out of mode a tile opens, in
2774
+ // mode it picks.
2775
+ selectedIds={pickingPhotos ? sel.selected : undefined}
2776
+ onFilePress={(f) =>
2777
+ pickingPhotos
2778
+ ? sel.toggle(f.id)
2779
+ : openPreview(photoDocs, photoDocs.findIndex((p: Doc) => p.id === f.id))
2780
+ }
2781
+ />
2782
+ </Section>
2783
+ </View>
2784
+
2616
2785
  {/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
2617
2786
  fields, three record references, mixed editor types, a derived
2618
2787
  warning, one "Show more".
@@ -3509,7 +3678,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3509
3678
 
3510
3679
  {/* ── the document desk's overlays */}
3511
3680
  <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
3512
- <Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
3681
+ <Button title="Remove" color="danger" icon="trash" onPress={removeSelected} />
3513
3682
  <Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
3514
3683
  <Button title="Use AI" color="primary" onPress={openAi} />
3515
3684
  </FloatingActionBar>
@@ -3814,7 +3983,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3814
3983
  ) : null}
3815
3984
  </DialogScrollArea>
3816
3985
  )}
3817
- {phase === "fork" ? (
3986
+ {phase === "running" ? (
3987
+ /* The one phase that used to have NO footer, which meant a run could not
3988
+ be stopped — the operator who sees from the first step that it is
3989
+ reading the wrong document had to sit through the whole thing and pay
3990
+ for it. Stop belongs HERE rather than under the transcript because the
3991
+ feed follows its newest part: a control inside it scrolls away exactly
3992
+ while the run is doing the thing they want to stop. `AgentRunActions`
3993
+ renders the same button for a pane-based surface (tpl_item_list); this
3994
+ dialog composes its own footers per phase, so it states it directly. */
3995
+ <DialogFooter>
3996
+ <Button title="Stop" color="secondary" onPress={stopRun} />
3997
+ </DialogFooter>
3998
+ ) : phase === "fork" ? (
3818
3999
  <DialogFooter>
3819
4000
  {uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
3820
4001
  <Button
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "29.3.0",
3
+ "version": "30.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/agent_run.tsx CHANGED
@@ -9,6 +9,7 @@ import { Markdown } from "./markdown";
9
9
  import { JsonPanel, stringifyData } from "./json_panel";
10
10
  import { PressableHighlight } from "./pressable_highlight";
11
11
  import { Marker, type StepStatus } from "./stepper";
12
+ import { NODE } from "./stepper_layout";
12
13
  import { AnimationFadeIn } from "./animation_fade_in";
13
14
  import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
14
15
  import { useLoticsLocale, type LoticsLocale } from "./locale";
@@ -498,7 +499,13 @@ const styles = StyleSheet.create({
498
499
  marginHorizontal: -8,
499
500
  paddingHorizontal: 8,
500
501
  },
501
- dotCol: { width: 18, alignItems: "center" },
502
+ // NODE, not a local 18. The dot IS a stepper `Marker` (exported from
503
+ // `stepper` precisely so this feed reuses the same one), and a Marker is
504
+ // NODE across. An 18px column centring a 24px disc overflows 3px each side,
505
+ // so the whole dot rail sat 3px LEFT of the surface's own edge — every other
506
+ // band in a dialog lined up and the run did not. Read the owner's constant
507
+ // rather than re-deriving a width that has to match it.
508
+ dotCol: { width: NODE, alignItems: "center" },
502
509
  rowBody: { flex: 1 },
503
510
  // Reasoning body aligns under the "Thinking" label (past the dot column + gap).
504
511
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
@@ -1,11 +1,12 @@
1
1
  import { useCallback, useMemo, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { AgentRun, type AgentRunProps } from "./agent_run";
4
+ import { Button } from "./button";
5
+ import { useLoticsLocale } from "./locale";
4
6
  import type { AgentUIPart } from "./agent_transform";
5
7
  import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
6
- import { DialogScrollArea } from "./dialog";
8
+ import { DialogScrollArea, useDialogGutter } from "./dialog";
7
9
  import { FollowScroll } from "./follow_scroll";
8
- import { useContainerSize } from "@lotics/ui/size_boundary";
9
10
 
10
11
  /**
11
12
  * What this pair needs from a run — structurally the shape `useAgentRun()`
@@ -36,6 +37,17 @@ export interface AgentRunLike {
36
37
  /** Non-null exactly while parked. */
37
38
  pendingChoice: { questions: AgentRunQuestion[] } | null;
38
39
  answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
40
+ /**
41
+ * STOP the run — server-side, not just locally. `useAgentRun().cancel` is
42
+ * exactly this; do NOT pass its `abort`, which detaches the listener and
43
+ * leaves the run executing (and billing) to completion.
44
+ *
45
+ * Without this the hook's capability was unreachable through the pane, so
46
+ * every surface built on it could only wait a run out. A long extraction is
47
+ * the case that matters: the operator sees from the first steps that it read
48
+ * the wrong document, and the only exit was to sit through it.
49
+ */
50
+ cancel: () => void;
39
51
  error: string | null;
40
52
  }
41
53
 
@@ -64,7 +76,12 @@ export interface AgentRunPaneProps {
64
76
  */
65
77
  export function AgentRunPane(props: AgentRunPaneProps) {
66
78
  const { run, labelForCall, renderToolOutput, onCancel } = props;
67
- const { small } = useContainerSize();
79
+ // The DIALOG's gutter, not this pane's own container measurement. Asking
80
+ // `useContainerSize()` here answered "small" for a 760px dialog while the
81
+ // header, scroll area and footer asked the SCREEN and answered "not small" —
82
+ // two honest answers to one question, and the run's rows landed 8px left of
83
+ // the dialog's own title. One decision, published by the Dialog, read here.
84
+ const gutter = useDialogGutter();
68
85
  const pending = run.pendingChoice;
69
86
 
70
87
  // Mapped once per question set, not per render: the wizard keys its per-step
@@ -100,7 +117,10 @@ export function AgentRunPane(props: AgentRunPaneProps) {
100
117
  // deliberate — remounting opens the question at the top rather than wherever
101
118
  // the feed happened to be scrolled to.
102
119
  return (
103
- <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
120
+ // The dialog's OWN gutter, read rather than re-derived: this pane sits as a
121
+ // band among the dialog's header and footer, and a hand-copied `small ? 16 :
122
+ // 24` here is a fifth copy of a number that has to agree with four others.
123
+ <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: gutter }}>
104
124
  {/* No empty-state slot on purpose. `AgentRun` renders its own breathing
105
125
  "Starting…" row while streaming with zero parts, localized through the
106
126
  `agentRun` locale slice — and ai_patterns states the law outright:
@@ -118,17 +138,42 @@ export function AgentRunPane(props: AgentRunPaneProps) {
118
138
  }
119
139
 
120
140
  /**
121
- * The run's action bar, for a `DialogFooter`. Renders the wizard's
122
- * Cancel/Back/Next/Submit while a question is up and NOTHING otherwise, so the
123
- * host can mount it unconditionally and let the run decide.
141
+ * The run's action bar, for a `DialogFooter`. The run decides what it holds, so
142
+ * the host mounts it unconditionally: the wizard's Cancel/Back/Next/Submit
143
+ * while a question is up, **Stop** while the run is streaming, nothing once it
144
+ * has settled.
124
145
  *
125
- * Pinned outside the scroller is the point: no length of question can push
126
- * Submit out of reach.
146
+ * Pinned outside the scroller is the point, and it is what decides that Stop
147
+ * lives here rather than under the transcript: the feed FOLLOWS its newest part,
148
+ * so a control inside it scrolls away from the reader exactly while the run is
149
+ * doing the thing they want to stop. No length of question can push Submit out
150
+ * of reach either.
151
+ *
152
+ * `onStop` is the host's way back — the screen to return to once the run is
153
+ * stopped, normally the intake it was started from. It is the same handler the
154
+ * pane's `onCancel` takes, because abandoning a parked question and stopping a
155
+ * streaming run land the operator in the same place; only the verb differs, and
156
+ * it differs truthfully (you STOP something running, you CANCEL something
157
+ * waiting).
127
158
  */
128
- export function AgentRunActions({ run }: { run: AgentRunLike }) {
159
+ export function AgentRunActions({ run, onStop }: { run: AgentRunLike; onStop?: () => void }) {
160
+ const loc = useLoticsLocale().agentRun;
129
161
  // Reads the SAME run the pane reads, rather than state the pane publishes: two
130
162
  // components deriving from one source cannot disagree, and nothing has to set
131
163
  // a parent's state during a child's render to keep them in step.
132
- if (run.pendingChoice == null) return null;
133
- return <ClarifyWizardActions />;
164
+ if (run.pendingChoice != null) return <ClarifyWizardActions />;
165
+ // Parked is NOT streaming — a run waiting on an answer burns nothing, so it is
166
+ // the wizard's Cancel that leaves it, not a Stop. Checked in this order for
167
+ // that reason.
168
+ if (run.status !== "streaming") return null;
169
+ return (
170
+ <Button
171
+ title={loc.stop}
172
+ color="secondary"
173
+ onPress={() => {
174
+ run.cancel();
175
+ onStop?.();
176
+ }}
177
+ />
178
+ );
134
179
  }
@@ -6,7 +6,7 @@ import { Icon } from "./icon";
6
6
  import { Divider } from "./divider";
7
7
  import { composeHandler, useFocusRing } from "./use_focus_ring";
8
8
  import { useHover } from "./use_hover";
9
- import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
9
+ import { CONTROL_RADIUS, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
10
10
  import { useAutoGrowHeight } from "./use_auto_grow_height";
11
11
  import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
12
12
  import { useLoticsLocale } from "./locale";
@@ -52,7 +52,10 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
52
52
  onPress={onSelect}
53
53
  {...focusProps}
54
54
  style={({ hovered, pressed }) => [
55
- { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
55
+ // Bleeds like every other washed row, so the option TEXT sits on the
56
+ // container's edge — level with the question above it, which no longer
57
+ // has to hand-pad itself to match.
58
+ { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
56
59
  // Selected reads as a persistent tint (zinc-100); an unselected row only
57
60
  // washes on hover (zinc-50). The check below reserves its slot always.
58
61
  selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
@@ -88,7 +91,7 @@ function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }
88
91
  <View
89
92
  {...(hoverProps as object)}
90
93
  style={[
91
- { paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
94
+ { paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
92
95
  // Same states as an option row: a filled custom answer reads as selected
93
96
  // (zinc-100); otherwise it washes on hover (zinc-50).
94
97
  selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
package/src/clarify.tsx CHANGED
@@ -36,9 +36,13 @@ export interface ClarifyProps {
36
36
  export function Clarify(props: ClarifyProps) {
37
37
  return (
38
38
  <View style={{ gap: 12 }}>
39
- {/* paddingHorizontal 8 aligns the eyebrow + question with the option/answer
39
+ {/* No padding: the `ChoiceList` rows below BLEED their wash now, so their
40
+ text already sits on this container's edge and the question needs
41
+ nothing to line up with it. The 8 that used to be here was compensation
42
+ for a row that pushed its own content in. Old note kept for the why:
43
+ it "aligned the eyebrow + question with the option/answer
40
44
  text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
41
- <View style={{ gap: 4, paddingHorizontal: 8 }}>
45
+ <View style={{ gap: 4 }}>
42
46
  {props.eyebrow ? (
43
47
  <Text size="xs" color="muted" weight="medium">
44
48
  {props.eyebrow}
@@ -123,12 +123,14 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
123
123
  return (
124
124
  <View style={{ gap: 16 }}>
125
125
  {/* The step position is an eyebrow above the question (no progress bar — and
126
- none at all for a single question); the footer aligns to the same 8px inset
126
+ none at all for a single question); the footer shares their left edge
127
127
  as the question and answers. Keyed by index so each step's custom-answer
128
128
  draft is its own (no bleed across steps). */}
129
129
  <Clarify key={index} eyebrow={total > 1 ? `${index + 1} / ${total}` : undefined} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
130
+ {/* No horizontal padding — the choice rows below BLEED their wash now, so
131
+ the footer already shares their left edge without compensating for it. */}
130
132
  {scope == null ? (
131
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
133
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
132
134
  <Button title={labels.cancel} color="muted" onPress={onCancel} />
133
135
  <View style={{ flexDirection: "row", gap: 8 }}>
134
136
  {index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
@@ -23,6 +23,31 @@ export const MIN_CONTROL_WIDTH = 160;
23
23
  * (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
24
24
  export const CONTROL_RADIUS = 10;
25
25
 
26
+ /**
27
+ * HOW FAR A ROW'S WASH EXTENDS PAST ITS CONTENT — and the invariant that goes
28
+ * with it: **a row that paints a hover/selection wash BLEEDS it outward; it
29
+ * never insets its content to make room for decoration.**
30
+ *
31
+ * `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal`
32
+ * is net zero for layout, so the row's CONTENT stays on the container's edge —
33
+ * flush with the headings, labels and prose around it — while the wash gets its
34
+ * breathing room by growing into the parent's padding.
35
+ *
36
+ * The alternative (pad without the negative margin) is what the kit did in
37
+ * three places, and it fails in a way nobody reports as a bug: the row's content
38
+ * sits N px right of everything else on the page, so every neighbour that has to
39
+ * line up with it — a summary line, a question above a choice list, a table's
40
+ * own header — gets hand-padded by the same N at its call site. The indent then
41
+ * looks deliberate because it is everywhere, and the compensation spreads to
42
+ * each new surface. An indent is legible only when something VISIBLE occupies
43
+ * it (a checkbox, an ordinal, a mark); an empty one is a defect.
44
+ *
45
+ * The parent must afford this much horizontal padding. 8 is chosen because it is
46
+ * the smallest breathing room that reads, and because every container in the kit
47
+ * already clears it (`Drawer` 20, `Dialog` 24, the page canvas 28).
48
+ */
49
+ export const ROW_WASH_BLEED = 8;
50
+
26
51
  /** How far {@link FOCUS_RING} extends BEYOND the control's border box. Module-local
27
52
  * on purpose: it is a FLOOR for anyone who clips (a container reserving less than
28
53
  * this on an edge a focusable child sits against shears that child's ring), never a
package/src/dialog.tsx CHANGED
@@ -50,6 +50,8 @@ export const DialogNavigationProvider = DialogNavigationContext.Provider;
50
50
  interface DialogContextValue {
51
51
  open: boolean;
52
52
  onOpenChange: (open: boolean) => void;
53
+ /** The horizontal gutter every band inside THIS dialog shares — see {@link useDialogGutter}. */
54
+ gutter: number;
53
55
  }
54
56
 
55
57
  const DialogContext = createContext<DialogContextValue | null>(null);
@@ -83,6 +85,39 @@ export interface DialogProps {
83
85
  initialRoute?: string;
84
86
  }
85
87
 
88
+ /**
89
+ * THE DIALOG'S HORIZONTAL GUTTER — every band inside a dialog reads this, so a
90
+ * header, a scroll area, a footer and any pane a caller drops in all share one
91
+ * left edge.
92
+ *
93
+ * It existed as the literal `small ? 16 : 24` at five sites (four here, one
94
+ * hand-copied into `AgentRunPane`), which is exactly how a band drifts: the copy
95
+ * has to be found and changed with the others, and the one that is missed is
96
+ * invisible until someone measures two bands against each other.
97
+ */
98
+ function dialogGutterFor(small: boolean): number {
99
+ return small ? 16 : 24;
100
+ }
101
+
102
+ /**
103
+ * THE gutter of the dialog you are inside — read, never re-derived.
104
+ *
105
+ * Centralising the VALUE was not enough, and that mistake shipped: `dialogGutter(small)` still
106
+ * left every band to work out `small` for itself, and `AgentRunPane` asked
107
+ * `useContainerSize()` (its own measured box, "small" at a 760px dialog) while the header,
108
+ * scroll area and footer asked `useScreenSize()` (the viewport, not small). Two honest answers
109
+ * to one question put the run's rows 8px left of the dialog's own title.
110
+ *
111
+ * So the Dialog decides ONCE and publishes it. A band inside a dialog gets that number
112
+ * whatever its own box measures; a surface rendered outside one falls back to the SCREEN — the
113
+ * same signal a dialog uses — so the two paths can never disagree about what "small" means.
114
+ */
115
+ export function useDialogGutter(): number {
116
+ const ctx = useContext(DialogContext);
117
+ const screenSize = useScreenSize();
118
+ return ctx?.gutter ?? dialogGutterFor(screenSize.small);
119
+ }
120
+
86
121
  export function Dialog(props: DialogProps) {
87
122
  const {
88
123
  testID,
@@ -127,12 +162,16 @@ export function Dialog(props: DialogProps) {
127
162
  onOpenChange(false);
128
163
  }, [onOpenChange]);
129
164
 
165
+ // Decided ONCE, here, and published — so a band never has to work out "small"
166
+ // for itself and land on a different answer than its siblings.
167
+ const gutter = dialogGutterFor(screenSize.small);
130
168
  const dialogValue = useMemo(
131
169
  (): DialogContextValue => ({
132
170
  open,
133
171
  onOpenChange,
172
+ gutter,
134
173
  }),
135
- [open, onOpenChange],
174
+ [open, onOpenChange, gutter],
136
175
  );
137
176
 
138
177
  const effectiveOffsetTop = screenSize.small ? 0 : offsetTop;
@@ -165,7 +204,7 @@ export function Dialog(props: DialogProps) {
165
204
  >
166
205
  <PortalHost>
167
206
  <View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
168
- <View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
207
+ <View style={[styles.closeButtonContainer, { paddingHorizontal: gutter }]}>
169
208
  <IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
170
209
  </View>
171
210
  <SizeBoundary style={styles.container}>{children}</SizeBoundary>
@@ -199,10 +238,10 @@ export interface DialogHeaderProps {
199
238
  export function DialogHeader(props: DialogHeaderProps) {
200
239
  const { children, style } = props;
201
240
  const { goBack, showBackButton } = useDialogNavigation();
202
- const screenSize = useScreenSize();
241
+ const gutter = useDialogGutter();
203
242
 
204
243
  return (
205
- <View style={[styles.header, { paddingHorizontal: screenSize.small ? 16 : 24 }, style]}>
244
+ <View style={[styles.header, { paddingHorizontal: gutter }, style]}>
206
245
  {showBackButton && <BackButton onPress={goBack} />}
207
246
  {children}
208
247
  </View>
@@ -244,10 +283,10 @@ export interface DialogScrollAreaProps {
244
283
 
245
284
  export function DialogScrollArea(props: DialogScrollAreaProps) {
246
285
  const { children } = props;
247
- const screenSize = useScreenSize();
286
+ const gutter = useDialogGutter();
248
287
 
249
288
  return (
250
- <ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
289
+ <ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}>
251
290
  {children}
252
291
  </ScrollView>
253
292
  );
@@ -264,12 +303,12 @@ export interface DialogFooterProps {
264
303
 
265
304
  export function DialogFooter(props: DialogFooterProps) {
266
305
  const { children, align = "end" } = props;
267
- const screenSize = useScreenSize();
306
+ const gutter = useDialogGutter();
268
307
 
269
308
  const justifyContent =
270
309
  align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
271
310
 
272
- return <View style={[styles.footer, { justifyContent, paddingHorizontal: screenSize.small ? 16 : 24 }]}>{children}</View>;
311
+ return <View style={[styles.footer, { justifyContent, paddingHorizontal: gutter }]}>{children}</View>;
273
312
  }
274
313
 
275
314
  // ============================================================================
@@ -351,7 +351,7 @@ export function FilesEditorRemove() {
351
351
  return (
352
352
  <Button
353
353
  title={labels.delete}
354
- color="danger-secondary"
354
+ color="danger"
355
355
  disabled={selected.length === 0}
356
356
  onPress={() => confirmRemove(selected, () => { for (const f of selected) onRemove(f.id); exit(); })}
357
357
  />
@@ -33,9 +33,20 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
33
33
  return (
34
34
  <View pointerEvents="box-none" style={styles.wrap}>
35
35
  <Card style={styles.bar}>
36
- <Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
37
- <Button title={clearLabel} color="muted" onPress={onClear} />
38
- {children}
36
+ {/* TWO GROUPS, not one row of peers. The count and Clear are the bar's
37
+ own STATE what is picked, and how to un-pick it; they act on the
38
+ selection, not on the records. `children` are the ACTS. A single gap
39
+ across all of them made four buttons read as four equal things, and
40
+ the emphasis ladder between them (muted / secondary / primary, which
41
+ encodes destructive vs benign vs promoted) then looked like
42
+ arbitrary variety rather than a ladder. Grouping states the kind
43
+ difference; the ladder is then read WITHIN the acts, where it means
44
+ something. */}
45
+ <View style={styles.state}>
46
+ <Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
47
+ <Button title={clearLabel} color="muted" onPress={onClear} />
48
+ </View>
49
+ <View style={styles.acts}>{children}</View>
39
50
  </Card>
40
51
  </View>
41
52
  );
@@ -48,13 +59,31 @@ const styles = StyleSheet.create({
48
59
  left: 0,
49
60
  right: 0,
50
61
  alignItems: "center",
62
+ // Keeps the bar off the screen edges at any width — and, with the bar's
63
+ // `maxWidth`, is what bounds it to the viewport on a phone.
64
+ paddingHorizontal: 16,
51
65
  zIndex: 20,
52
66
  },
53
67
  bar: {
54
68
  flexDirection: "row",
55
69
  alignItems: "center",
56
- gap: 12,
70
+ justifyContent: "center",
71
+ // A count plus an escape plus three verbs is ~520px of content — wider than
72
+ // a phone. Unbounded it simply hung off both edges, taking the promoted act
73
+ // with it, and no host could fix that without re-authoring the bar. So it
74
+ // WRAPS, and the grouping decides where: `state` and `acts` are each a
75
+ // nowrap row, so the only break available is the boundary between them —
76
+ // the line splits exactly where the meaning does (what is picked / what to
77
+ // do with it), never mid-ladder with Download orphaned from Use AI.
78
+ flexWrap: "wrap",
79
+ maxWidth: "100%",
80
+ // The biggest boundary in the bar — state | acts — gets the most air. The
81
+ // two groups below sit on the kit's action gap. Doubles as the row gap once
82
+ // wrapped, which is the same boundary seen vertically.
83
+ gap: 20,
57
84
  paddingVertical: 10,
58
85
  paddingHorizontal: 16,
59
86
  },
87
+ state: { flexDirection: "row", alignItems: "center", gap: 8 },
88
+ acts: { flexDirection: "row", alignItems: "center", gap: 8 },
60
89
  });
package/src/locale.tsx CHANGED
@@ -193,6 +193,7 @@ export interface LoticsLocale {
193
193
  output: string;
194
194
  awaiting: string;
195
195
  retry: string;
196
+ stop: string;
196
197
  tools: Record<string, string>;
197
198
  steps: (n: number) => string;
198
199
  };
@@ -341,6 +342,7 @@ export const en: LoticsLocale = {
341
342
  output: "Output",
342
343
  awaiting: "Awaiting",
343
344
  retry: "Retry",
345
+ stop: "Stop",
344
346
  tools: {
345
347
  query_records: "Searching records",
346
348
  get_record: "Reading a record",
@@ -493,6 +495,7 @@ export const vi: LoticsLocale = {
493
495
  output: "Kết quả",
494
496
  awaiting: "Chờ duyệt",
495
497
  retry: "Thử lại",
498
+ stop: "Dừng",
496
499
  tools: {
497
500
  query_records: "Đang tìm dữ liệu",
498
501
  get_record: "Đang đọc một bản ghi",
@@ -1,4 +1,5 @@
1
1
  import { ReactNode, Ref, useCallback, useState } from "react";
2
+ import { ROW_WASH_BLEED } from "./control_surface";
2
3
  import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
4
  import { colors } from "./colors";
4
5
  import { pressSelectedText } from "./press_selection";
@@ -108,23 +109,26 @@ const styles = StyleSheet.create({
108
109
  // the text it had no business covering.
109
110
  ...({ cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
110
111
  },
111
- // THE register row — FULL-WIDTH rounded highlight. The hover/selected wash
112
- // spans the entire row (so the pressable area === the row, never narrower than
113
- // the dividers around it) and rounds (radius 10); content sits on the 20px
114
- // column gutter so a Table header + cells align. The Divider between rows (the
115
- // Table renders it) is the resting separation; this wash is the hover state.
112
+ // THE register row — FULL-WIDTH rounded highlight that BLEEDS past the content
113
+ // (`ROW_WASH_BLEED`, net-zero margin+padding), so the row's content sits on the
114
+ // container's own edge and the wash still has breathing room. It used to pad 20
115
+ // WITHOUT the negative margin, which put every table's first column 20px right
116
+ // of the headings above it and made `SummaryLine` call sites hand-pad 20 to
117
+ // catch up. `inset` below has always done it correctly; this is the same rule.
116
118
  register: {
117
119
  borderRadius: 10,
118
- paddingHorizontal: 20,
120
+ paddingHorizontal: ROW_WASH_BLEED,
121
+ marginHorizontal: -ROW_WASH_BLEED,
119
122
  },
120
123
  // Square, full-bleed — lines up with the Dividers between register rows. Legacy.
121
124
  bleed: {
122
- paddingHorizontal: 20,
125
+ paddingHorizontal: ROW_WASH_BLEED,
126
+ marginHorizontal: -ROW_WASH_BLEED,
123
127
  },
124
128
  // Rounded, pulled in from the edge — matches the Accordion's inset rows.
125
129
  inset: {
126
130
  borderRadius: 8,
127
- paddingHorizontal: 8,
128
- marginHorizontal: -8,
131
+ paddingHorizontal: ROW_WASH_BLEED,
132
+ marginHorizontal: -ROW_WASH_BLEED,
129
133
  },
130
134
  });
package/src/table_fit.ts CHANGED
@@ -30,10 +30,22 @@ export interface TableFitColumn {
30
30
  /** Gap between the header band's / a row's children — `table.tsx` styles consume
31
31
  * this, so the fit math and the rendered geometry can't drift. */
32
32
  export const COLUMN_GAP = 14;
33
- /** The register's horizontal gutter — mirrors `PressableRow`'s register variant
34
- * (`paddingHorizontal: 20`), the one geometry this file models but doesn't own.
35
- * `table.tsx` consumes it for the header band and static rows. */
36
- export const ROW_GUTTER = 20;
33
+ /**
34
+ * The register's CONTENT gutter zero, because the washed row bleeds instead.
35
+ *
36
+ * It was 20, mirroring `PressableRow`'s register variant, and every unwashed
37
+ * part of a table (the header band, static rows, an expanded detail) padded by
38
+ * the same 20 purely to line up with the washed row's inset content. That put a
39
+ * table's first column 20px right of the section heading above it with nothing
40
+ * visible in the gap. The wash now grows outward (`ROW_WASH_BLEED`) rather than
41
+ * pushing content in, so every part of a table sits on its container's edge and
42
+ * the five `table.tsx` sites that read this constant follow automatically.
43
+ *
44
+ * Kept as a named constant rather than deleted: the header band, the rows and
45
+ * the fit math must agree, and a literal repeated at five sites is how they
46
+ * stopped agreeing before.
47
+ */
48
+ export const ROW_GUTTER = 0;
37
49
  const ROW_H_PADDING = ROW_GUTTER * 2;
38
50
  /** Fit-math width a flexible column needs to stay usable — below this the flex
39
51
  * column is crushed to ellipsis soup, so it counts as this wide when deciding