@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.
@@ -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.2.0",
3
+ "version": "30.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -324,6 +324,7 @@
324
324
  "test": "vitest run"
325
325
  },
326
326
  "devDependencies": {
327
+ "@lotics/shared": "*",
327
328
  "@types/react-dom": "~19.2.2",
328
329
  "@vitejs/plugin-react": "^4.3.4",
329
330
  "lucide-react": "^0.562.0",
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
  // ============================================================================