@lotics/ui 29.2.0 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +23 -1
- package/MIGRATION.md +23 -0
- package/docs/ai_patterns.md +34 -3
- package/docs/catalog.md +7 -1
- package/docs/composition.md +80 -5
- package/docs/data_entry.md +13 -0
- package/examples/tpl_item_list.tsx +39 -6
- package/examples/tpl_record.tsx +187 -6
- package/package.json +2 -1
- package/src/agent_run.tsx +8 -1
- package/src/agent_run_pane.tsx +57 -12
- package/src/choice_list.tsx +6 -3
- package/src/clarify.tsx +6 -2
- package/src/clarify_wizard.tsx +4 -2
- package/src/control_surface.ts +25 -0
- package/src/dialog.tsx +47 -8
- package/src/file_intake.ts +17 -2
- package/src/files_editor.tsx +1 -1
- package/src/floating_action_bar.tsx +33 -4
- package/src/locale.tsx +3 -0
- package/src/mime.ts +76 -0
- package/src/pressable_row.tsx +13 -9
- package/src/table_fit.ts +16 -4
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
|
|
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
|
|
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 === "
|
|
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": "
|
|
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
|
-
|
|
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 },
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`.
|
|
122
|
-
* Cancel/Back/Next/Submit
|
|
123
|
-
*
|
|
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
|
|
126
|
-
*
|
|
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
|
|
133
|
-
|
|
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
|
}
|
package/src/choice_list.tsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
{/*
|
|
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
|
|
45
|
+
<View style={{ gap: 4 }}>
|
|
42
46
|
{props.eyebrow ? (
|
|
43
47
|
<Text size="xs" color="muted" weight="medium">
|
|
44
48
|
{props.eyebrow}
|
package/src/clarify_wizard.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
package/src/control_surface.ts
CHANGED
|
@@ -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:
|
|
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
|
|
241
|
+
const gutter = useDialogGutter();
|
|
203
242
|
|
|
204
243
|
return (
|
|
205
|
-
<View style={[styles.header, { paddingHorizontal:
|
|
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
|
|
286
|
+
const gutter = useDialogGutter();
|
|
248
287
|
|
|
249
288
|
return (
|
|
250
|
-
<ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal:
|
|
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
|
|
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:
|
|
311
|
+
return <View style={[styles.footer, { justifyContent, paddingHorizontal: gutter }]}>{children}</View>;
|
|
273
312
|
}
|
|
274
313
|
|
|
275
314
|
// ============================================================================
|