@lotics/ui 11.3.0 → 11.5.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.
@@ -0,0 +1,398 @@
1
+ # Data entry — which pattern for which job
2
+
3
+ How data gets captured and edited in a `@lotics/ui` screen: inline edit, forms, find-or-create,
4
+ line items, handoffs, phased records, billing, tags, dispositions, attachments, and stage gates.
5
+ This is the most common thing to get right — read it before building any editing surface, and
6
+ match the JOB to the pattern below. Exact prop APIs are the shipped sources — `../src/<name>.tsx`
7
+ (never guess a prop; open the file). The component inventory lives in [the catalog](./catalog.md),
8
+ layout/color laws in [the composition grammar](./composition.md), and full worked-example screens
9
+ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
10
+
11
+ ## The decision table
12
+
13
+ | You're capturing… | Reach for | Why |
14
+ |---|---|---|
15
+ | an EXISTING record's fields | [**Inline edit**](#inline-edit--the-preferred-way-to-edit-an-existing-record) (`Inline*`) | edit in place, no form mode |
16
+ | a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
17
+ | a RELATED record (pick or make) | [**find-or-create**](#find-or-create--the-combobox-family-is-the-control) (`Combobox allowCustom`) | one control covers both |
18
+ | REPEATING rows you build & revise | [**line items**](#line-items--create--preview--edit-a-composition-not-a-primitive) (create→preview→edit) | add / edit / remove, live totals |
19
+ | CHARGES that bill onto documents | [**billing**](#billing--the-invoice-document-is-the-unit) (`tpl_record` Billing section) | the invoice document is the unit |
20
+ | a record's FEE/charge SUMMARY | [**`Ledger`**](#fee-summary--ledger) (worked example: `tpl_item_list` drawer) | grouped money lines, one emphasized total — no bars/charts |
21
+ | a multi-value TAG field | [**`Select multi`**](#tag--multi-value-field--select-multi) (`renderSelected` → `Chip`) | chips composed, not a separate control |
22
+ | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
23
+ | a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
24
+ | FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery) | add / preview / delete |
25
+ | a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
26
+
27
+ ## Inline edit — the preferred way to edit an existing record
28
+
29
+ When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
30
+ form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
31
+ chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
32
+ layout), click swaps the input in **at the same height** (zero reflow, the whole point), and it
33
+ commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ primary / ✕).
34
+
35
+ One per type:
36
+
37
+ - **`InlineTextInput`** — plain text; `struck` renders the value struck-through (a done task title).
38
+ - **`InlineNumberInput`** — `format` formats the RESTING value (currency, units); edit mode is a
39
+ bare number input with `min`/`max`.
40
+ - **`InlineSelect`** — plain options OR `renderOptionContent`; floats an `OptionList` in a popover
41
+ so the row never grows; the RESTING value renders like its option (`renderOptionContent` by
42
+ default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label.
43
+ - **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
44
+ `MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
45
+ - **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
46
+ ADD/REMOVE a time — the value's own shape, date vs datetime, is the source of truth
47
+ (`optionalTime` is ignored when `format="datetime"`).
48
+ - **`InlineTimePicker`** — a time-only value.
49
+ - **`InlineTagSelect`** — the MULTI member — a tag SET in the inline vocabulary: selected tags
50
+ render as badges inside the standard chip, clicking floats a multi `OptionList` (checkbox rows),
51
+ CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
52
+ field.
53
+
54
+ All are built on **`useInlineEdit`** + **`InlineEditView`** (custom inputs join the family via
55
+ those). `onSave` is async: the saving spinner sits INSIDE the control at its right edge (never a
56
+ sibling — that reflows); an error shows inline without losing the edit.
57
+
58
+ To let a value be UNSET (a diff-write CLEAR — unassign, remove a due date, drop a select), pass
59
+ **`onClear`** to `InlineSelect` / `InlineMemberSelect` / `InlineDatePicker`. It surfaces through
60
+ each popover's OWN clear affordance — `InlineSelect` renders a compact left-aligned "Clear"
61
+ `Button` below the `OptionList` (only while a value is set); `InlineDatePicker` reuses the
62
+ calendar's own footer "Clear" button. Never a bolted-on sibling row (it can't reach the option
63
+ list's active-highlight and doubles the footer hairline), and never a persistent ✕ on the resting
64
+ cell (noise on a dense board, and a pressable nested in the trigger is invalid DOM). `onSave`'s
65
+ `next` stays non-null — a caller opts in per field; the clear fires `onClear`, which writes `null`
66
+ (the app workflow must ACCEPT null on that input — a `select`/`date`/`member` field clears on
67
+ null). `InlineTagSelect` (multi) needs no `onClear` — an empty set is already a valid `onSave`.
68
+
69
+ ### The row stack — `DetailTable` + `DetailRow`
70
+
71
+ A STACK of rows lives in a `DetailTable` (label · value · trailing laid out like a TABLE:
72
+ `labelWidth` / `trailingWidth` / `minHeight` set ONCE on the parent, plus the 6px row gap the
73
+ zinc-50 chips need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a trailing
74
+ action/badge, so EVERY row reserves the column and one row's `Copy` button never makes its editor
75
+ narrower than its neighbours'.
76
+
77
+ ### The editability affordance
78
+
79
+ An editor AT REST sits on a zinc-50 chip — THE editability affordance: users see what's editable
80
+ without hovering. `background="transparent"` opts a field out of the chip — for DENSE,
81
+ uniformly-editable surfaces (a task list/board where EVERY cell edits: the chip repeated
82
+ everywhere is noise and distinguishes nothing; hover/focus still reveal the input). Keep the chip
83
+ wherever editable and static values MIX. A `disabled` editor rests FLAT automatically — the chip
84
+ is the editability promise, and an inert field must not make it.
85
+
86
+ A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
87
+ **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
88
+ border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
89
+ glance and the static value never reads as a disabled input.
90
+
91
+ To hang a right-side action/badge off a row, use `DetailRow`'s `trailing` slot (NOT for units —
92
+ "kg"/"$" belong IN the value via `InlineNumberInput format`) — see the "Details" `DetailTable` of
93
+ `tpl_record`, which also reads top→bottom as a full record surface (header → fields →
94
+ `DangerZone`). Not every field is a same-height swap — a tag field, a status, or an attachment
95
+ grid edit in place too (below).
96
+
97
+ ## Fieldset form — fields lay out on a RESPONSIVE two-column grid
98
+
99
+ Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
100
+ columnGap:16`; each `FormField` declares an intrinsic width via `style` — `half` (`flexGrow:1,
101
+ flexBasis:240`) or `full` (`flexGrow:1, flexBasis:"100%"`) — so two halves sit side-by-side on a
102
+ wide card and stack on a narrow one with zero media queries. Pair short, related fields as halves
103
+ (phone/email, qty/price); give long or singular values the full row (legal name, address, notes).
104
+ Past two columns the label→field link breaks — MANY inputs means GROUPING into labeled fieldsets
105
+ (each its own 2-up grid, `Divider` between), never a third column. `FormDatePicker`/`FormPicker`
106
+ wrap their OWN `FormField` (and don't put a `style` on it) — for a grid cell use a bare
107
+ `FormField style={half}` wrapping `DatePicker`/`Picker`.
108
+
109
+ ## Find-or-create — the `Combobox` family IS the control
110
+
111
+ `Combobox` is COMPOUND: a root holds the DATA + behaviour (the shared `useOptionList` engine —
112
+ `options`, `value`, `onValueChange`, `onSearchChange`, `allowCustom`, the per-row
113
+ `getOptionDescription`/`renderOptionContent` so they're typed against the option `data`), and the
114
+ parts render the CHROME, composed as children — never a render-prop pile:
115
+
116
+ ```tsx
117
+ <Combobox options={hits} value={sel} onSearchChange={setQ} onValueChange={pick} allowCustom
118
+ customOptionLabel={(q) => `Create "${q}"`} getOptionDescription={(o) => o.data?.code}>
119
+ <ComboboxInput icon="search" clearable onClear={deselect} placeholder="Find or create…" />
120
+ <ComboboxContent recentsLabel="Recent">
121
+ <ComboboxEmpty>No match — type a name to create one</ComboboxEmpty>
122
+ <ComboboxFooter>{validity}</ComboboxFooter>
123
+ </ComboboxContent>
124
+ </Combobox>
125
+ ```
126
+
127
+ It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
128
+ search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
129
+ large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
130
+ the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
131
+ pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
132
+ set (on focus an empty field already browses everything — `recentOptions ?? all`).
133
+
134
+ `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query matches no option,
135
+ emitting the typed text as the value — so a value not in the known set means CREATE. Wire that
136
+ branch to a create overlay (an anchored popover for a 1–2 field gate, a modal `Dialog` for 3+)
137
+ that builds the new record and attaches it; existing matches attach directly. A record-CREATION
138
+ dialog stays a MINIMAL gate and really creates — the row lands and its own workspace opens for
139
+ refinement (an EMPTY checklist, commons as ghosts). NEVER a creation wizard: create-then-refine
140
+ puts complexity on the record surface, not in front of it.
141
+
142
+ The create row sits BELOW matches by default (the keyboard highlight lands on the first MATCH, so
143
+ Enter on a partial picks it, never a duplicate); pass `customOptionPlacement="top"` to pin it
144
+ above. Use `reflectSelection={false}` and render the attached record below as a card with a
145
+ Change action.
146
+
147
+ For input-level status that must stay visible while results scroll (validity feedback on the typed
148
+ value, a result count, a secondary "create" affordance), drop a `ComboboxFooter` into the content —
149
+ a pinned row below the listbox, OUTSIDE keyboard option-nav; call `useCombobox().close()` from a
150
+ footer action that hands off elsewhere (so the popover dismisses cleanly instead of floating over
151
+ what you navigated to). A `ComboboxEmpty` child gives the no-match state richer content than the
152
+ `ComboboxContent emptyText` string.
153
+
154
+ ## Line items — create → preview → edit (a composition, not a primitive)
155
+
156
+ For a list of records you build then revise (invoice rows, repair lines, config entries), each
157
+ item is a `Card` toggling between a read-only PREVIEW (a stack of `DetailRow`s) and an EDIT form
158
+ (the inputs), via a local `editing` flag + Edit/Save/Cancel in the `CardFooter`. A freshly-added
159
+ item opens in edit; Save collapses it to the preview; Edit reopens it. **Edit is cancelable** —
160
+ snapshot the item on Edit so Cancel reverts, or DISCARDS a freshly-added one. Duplicate sits left;
161
+ the destructive **Delete is `danger-secondary`**; Cancel + the Save/Edit toggle sit right. Every
162
+ screen composes its own (~15 lines) so the preview rows + form fit the data.
163
+
164
+ ## Handoff — a stage transition, never a message
165
+
166
+ When work crosses departments (sales → operations → accounting), the HANDOFF is the RECORD
167
+ changing desks — never an inbox, a notification, or a copied task. Two types:
168
+
169
+ **(A) Same entity** → a STAGE field on the shared record: sections carry OWNER dot tags, and the
170
+ handoff is MANAGED AS TASKS — each desk's checklist on the record (the task-row vocabulary:
171
+ sibling `CheckCircle` + struck transparent `InlineTextInput` + per-task `InlineMemberSelect`
172
+ assignees + the `CaptureRow` add-affordance — never a decorative progress strip). A NEW record
173
+ starts with an EMPTY checklist — tasks truly vary. The commons split in two: MANDATORY tasks are
174
+ seeded by the app (a workflow on create, per record type) — no human types them; common-but-
175
+ OPTIONAL tasks appear as `SuggestionChip`s under the list — a PILL, never a row, so a suggestion
176
+ can't be mistaken for a task (tap = materialize, ✕ = dismiss for this record; already-present
177
+ labels filter out; suggestions never count in done/total and pause while a filter narrows the
178
+ view) — the same suggestion grammar as the billing Standard pill. Real rows carry a ⋯ `menu` (an
179
+ `ActionMenu`; Delete lives BEHIND it, danger-styled and last — never a bare ✕ a stray tap can
180
+ hit), completing the checklist's CRUD.
181
+
182
+ Open tasks INFORM the handoff, they NEVER block it: the CTA stays enabled, the count warns, and
183
+ open tasks carry over. The handoff CTA opens a DIALOG for the receiving desk (assignee
184
+ `MemberSelect` + an optional note; the open-task warning inside); confirming closes the drawer —
185
+ the record left this register.
186
+
187
+ Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
188
+ whose popover holds the checklist on FIXED columns — every row ring · struck `InlineTextInput`
189
+ title (flex) · quick-reassign `InlineMemberSelect` (140) — no expandable rows (tags/files depth is
190
+ the Task list template's lesson, not the peek's); the popover body is `PopoverContent`'s own
191
+ ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
192
+ `OptionList`). The DRAWER carries a real Tasks SECTION in the Record template's shape (heading +
193
+ the compact meter + the same checklist) — one shared task state per record feeds the column, the
194
+ peek, the section, and the handoff dialog; a section owned by a later desk sits visible but GATED
195
+ ("Billing opens when the record reaches Accounting"). Registers scope by stage — the receiving
196
+ department's register IS its inbox. The sender's sections stay editable after handoff (the gate
197
+ transfers RESPONSIBILITY, not access — locking is app IAM, not template grammar). Worked example:
198
+ `tpl_record`.
199
+
200
+ **(B) Different entity** (a deal → a shipment) → SPAWN-AND-LINK: the upstream record's terminal
201
+ gate CREATES the downstream record on the shared spine, linked both ways (a linked-record row
202
+ each side); usually TWO apps — the upstream closes its own lifecycle, the downstream starts fresh
203
+ in its department's app. Never merge the two lifecycles into one record.
204
+
205
+ ## Sequential phases on one record + the outline rail
206
+
207
+ When a record's work happens in ORDERED PHASES (check-in → check-out; receive → dispatch), the
208
+ phases are NOT tabs or a segmented control — hiding the other phase loses the context the current
209
+ one needs and buries why its gate is blocked. Both phase sections sit on ONE page in
210
+ chronological order; each phase owns ITS OWN fee rows + phase total (the invoice-band idea one
211
+ level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
212
+ its blockers ("Confirm check-in first."). The header status chip carries the phase.
213
+
214
+ A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
215
+ (scroll-spy: jump to a section, the highlight follows the scroll); on narrow containers the rail
216
+ becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
217
+ per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
218
+ per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
219
+ create step, never a toggle on the record.
220
+
221
+ ## Billing — the invoice DOCUMENT is the unit
222
+
223
+ When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
224
+ split the screen into "enter fees here, issue there" — that smears one job across two places.
225
+ Make **each invoice a FLAT hairline-set band that holds its own editable charge lines** (amount
226
+ input + payment method), its **live total**, its **status badge** (nothing-to-bill · draft ·
227
+ issued + ref), and its **issue action** in its closing row — no card chrome; data-capture
228
+ templates are flat (see [the composition grammar](./composition.md)). A charge never lives apart
229
+ from the document it bills on. Issuing is gated **inline, never a dead end**: when a prerequisite
230
+ is missing (a bill-to tax ID, a method on a charged line) the issue button disables with one muted
231
+ line saying what's needed; the payment-method picker turns required the instant a line carries an
232
+ amount. Issuing a real e-invoice is irreversible → confirm in a `Dialog` (stage gate). A
233
+ grand-total **receipt** validates first — surface the EXACT missing methods (`Alert.alert` listing
234
+ each) rather than a vague "incomplete." A refundable **deposit** is its own card and its own
235
+ receipt — never folded into the total due. Composition over `Card` + `NumberInput` + `Picker` +
236
+ `Badge`; no new primitive. Worked example: `tpl_record`'s Billing section.
237
+
238
+ ## Fee summary — `Ledger`
239
+
240
+ For a record's fee/charge SUMMARY (read-heavy, one payment action), reach for the `Ledger`
241
+ compound: **`LedgerGroup`** (label + group sum) → **`LedgerRow`** (label · `meta` · ONE right
242
+ tabular money column; `peek` makes the row a door to its PARTICULARS in an anchored popover —
243
+ references live INSIDE the peek, since a row is never a button holding another button (`reference`
244
+ is ignored while `peek` is set); `reference` = a trailing link on a non-peek row — an issued
245
+ invoice, a receipt no.) → **`LedgerTotal`** (divider-set emphasized close, `zeroLabel` for the
246
+ settled state, `tone` for a danger balance). The root `Ledger` takes `formatValue` so every row
247
+ formats money one way. Pair it with a Record-payment POPOVER that appends a receipt. No
248
+ bars/charts. Worked example: the `tpl_item_list` drawer.
249
+
250
+ ## Tag / multi-value field — `Select multi`
251
+
252
+ A tag field's resting state should be a tidy CHIP BOX — and that's just a multi `Select` whose
253
+ `renderSelected` returns a removable `<Chip onDismiss={remove}>`. There's NO separate component
254
+ and no `display` mode: `renderSelected(item, { remove })` composes the anchor — render a `Chip`
255
+ with `remove` for the ✕, or a plain `OptionBadge`/`MemberChip`/custom pill that ignores it;
256
+ `renderOptionContent` renders the menu rows. `searchable` adds the filter field, `allowCustom` the
257
+ create row. Borderless for a grid cell? Pass a `style`, never a `variant`. `Combobox` is the
258
+ SINGLE-value sibling — for a search that emits one pick at a time and renders the selection
259
+ elsewhere, use `reflectSelection={false}` (see the [templates](./templates.md)).
260
+
261
+ ## Disposition — lifecycle status is ASYMMETRIC by phase
262
+
263
+ When a status is an OPEN default plus terminal OUTCOMES the user decides (Lead → Closed/Lost,
264
+ Draft → Confirmed/Cancelled, Open → Approved/Rejected), a 3-way segmented/dropdown is wrong — it
265
+ treats a lifecycle as flat peers and guides neither the decision nor the revision. Split by
266
+ phase: while OPEN, surface the outcomes as ACTIONS with valence (`Mark closed` = `primary`,
267
+ `Mark lost` = `danger-secondary`; the open state a quiet dot `Badge`). Coloring the buttons is
268
+ right here — a SINGLE record-level decision is not the approvals-queue "wall of loud buttons."
269
+ Once RESOLVED, show the colored STATE (`Badge variant="dot"` — emerald positive / red negative /
270
+ blue open) with a quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each state a colored
271
+ dot + label, current marked + disabled), not a generic text menu. A composition (Badge + Button +
272
+ Popover/MenuButton).
273
+
274
+ ## Attachments — a full add / preview / DELETE field
275
+
276
+ ### Default: `FilesEditor`
277
+
278
+ **`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
279
+ (Upload · Select · Download all) + a batch SELECT mode + the full-screen gallery +
280
+ Alert-confirmed remove; the host only owns `files` and wires `onAdd` (picked → its upload) /
281
+ `onRemove`. The destructive per-tile ✕ shows only in SELECT mode (the default view is a clean
282
+ preview — no stray-tap deletes); pass `selectTileRemove={false}` to drop that ✕ entirely so
283
+ select mode deletes ONLY via Select → Menu → Delete (the batch gating flow — right for a
284
+ height-bounded cell editor). The built-in gallery is Download + inline preview (no "open in new
285
+ tab" — it's redundant once everything previews inline). In a height-bounded container (a
286
+ popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar pins below it; omit it
287
+ in free-flow layouts (a form field) where the grid grows.
288
+
289
+ The rest of the surface: `uploads` passes the live add-queue through to the grid (with
290
+ `onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload action's built-in web
291
+ picker (e.g. a native document picker); `readOnly` (or omitting `onRemove`) makes it a view-only
292
+ download+preview surface; omitting `onAdd` hides Upload. Select mode gains a Share action via
293
+ `onShareSelected(files)` and a "Download as ZIP" item via `onDownloadZipSelected(files)` (shown at
294
+ 2+ selected) — both host-provided, hidden when omitted. `onDownload` overrides the default
295
+ per-file `downloadFileFromUrl`; `accept` filters the picker; `itemSize`/`minItemWidth`/`columns`
296
+ pass through to the grid; `credentials` covers auth-gated preview URLs; `labels` /
297
+ `galleryLabels` localize the toolbar and the gallery chrome.
298
+
299
+ Reach for the lower-level pieces below only when you need custom chrome.
300
+
301
+ ### The pieces — `FileDropzone`, `FileGrid`, `FileGalleryModal`, `FileRow`
302
+
303
+ Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
304
+ accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`**
305
+ ABOVE the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add
306
+ more" affordance — only the empty state leads with it).
307
+
308
+ `FileGrid` is the upload-aware grid: `files` are the saved/completed `DisplayFile`s, `uploads` is
309
+ the LIVE add-queue (`FileUpload[]`) — it interleaves both and renders each in-flight tile itself —
310
+ a LABELED status overlay (uploading spinner · "Retrying" · "Paused" · "Upload failed" + a retry
311
+ button · "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
312
+ `FileThumbnail`. Localize the labels with `labels.upload` (an `UploadStatusLabels`) and
313
+ `labels.retryAll`. **Limitation:** the upload-status labels are NOT wired to
314
+ `LoticsLocaleProvider` — they default to English; pass `labels` per instance to localize.
315
+
316
+ Make it CRUDable by wiring its callbacks: `onFilePress` → set a `number|null` index that drives
317
+ `<FileGalleryModal files activeIndex onIndexChange>` — a FULL-SCREEN viewer with a toolbar
318
+ (filename · counter · actions — download, optional `onOpenExternal`/`onRemove` · close-✕),
319
+ prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a phone (`useScreenSize().small`) the
320
+ actions collapse into a `⋯` `ActionMenu` so the close-✕ never overflows off-screen (its popover
321
+ portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks one, which is why an inline
322
+ popover would render behind the overlay). The 90° rotate controls for images are NOT in the
323
+ toolbar — they FLOAT as a pill overlaid on the image (every screen size).
324
+ `onDisplayRemove`/`onUploadRemove` → drop the file / cancel the upload (the grid renders a ✕ on
325
+ each tile automatically); `onRetry` / `onRetryAll` for failed uploads.
326
+
327
+ In a sandboxed custom-code app the gallery's "open in new tab" can't pop a window — pass
328
+ `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the action hides).
329
+ PDF/Word/Excel/CSV all render INLINE here (pdf.js / `@lotics/docx` / `@lotics/xlsx`, lazy-loaded) —
330
+ no native `<iframe>` viewer, so they work inside a cross-origin app iframe. Keep
331
+ label+grid+dropzone on a `gap` (a bare `CardBody` has none).
332
+
333
+ By default `FileGrid` tiles FILL the container width — a uniform size (≥ `minItemWidth`, default
334
+ 96) so full rows span the width and reflow as it changes; a short last row keeps that size and
335
+ left-aligns (no stretched tiles, no empty cells). Pass `columns` for a fixed column count, or
336
+ `itemSize` for exact fixed-size tiles. For a dense strip in a FIXED-width container that must NOT
337
+ wrap, pass `singleRow` — it fits as many tiles as the width allows and collapses the rest into a
338
+ clickable "+N" overflow tile (`onOverflowPress(hiddenCount)`, which the host wires — e.g. open the
339
+ gallery at the first hidden file). This is the responsive overflow pattern (avatar stack /
340
+ file-manager); prefer it over a hardcoded `maxVisible`, which can't adapt to width.
341
+
342
+ Under the hood it composes `FileThumbnail` (the completed tile — right surface per MIME: image
343
+ thumbnail · a doc tile with the `FileBadge` centered + a single-line filename · media card; also
344
+ takes `isTemplate` to overlay a TMPL marker) and `UploadingThumbnail` (the in-flight tile); reach
345
+ for either directly only when hand-rolling a NON-grid layout. **When the filename must be
346
+ readable, use `FileRow`** — a horizontal LINE (`FileBadge` or a `placeholder` + the FULL name + a
347
+ meta line + a composable `trailing` slot). `onPress` makes the whole row a pressable door (open
348
+ the file); `trailing` (a status `Badge`, an action, a remove ✕) stays an independently-pressable
349
+ sibling. For attachment lists / message files / document checklists where a square tile truncates
350
+ the name.
351
+
352
+ Never hand-build a drop well, a file grid, the upload tiles, or a preview modal. In a custom-code
353
+ APP, don't hand-roll the upload+preview state either — `@lotics/app-sdk` `useAttachments()` gives
354
+ the chat's instant-preview lifecycle (local object-URL now, `file_id` on complete); map each
355
+ `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file: {…} }`, else
356
+ `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `FileGrid`'s
357
+ `uploads` — `onUploadRemove={remove}`.
358
+
359
+ ### Gated file CRUD — a PATTERN, composed locally, not a sealed kit component
360
+
361
+ When a file field must GUARD deletion (operator-facing, accidental delete is a real risk), DON'T
362
+ use `FileGrid` (its per-tile ✕ is exactly the stray-tap risk you're guarding against) — compose it
363
+ in the app from the kit pieces; don't reach for a one-size widget (the action set + layout vary
364
+ per app — each host composes its own). The recipe: `FileDropzone` (add) + `FileThumbnailGrid
365
+ files selectedIds onFilePress` (pass `selectedIds` ONLY in select mode) + `FileGalleryModal`
366
+ (view + rotate + persist) + a `Dialog` confirm, all driven by **`useSelectionMode()`** — the
367
+ reusable LOGIC. The gating rule: **NO per-thumbnail ✕** — delete is **Select → ⋯ menu → Delete →
368
+ confirm**, so a stray tap never removes a file. Three reusable primitives back it (the logic is
369
+ shared; the action bar + layout + copy stay local):
370
+
371
+ - **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit,
372
+ toggle, toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's
373
+ `selectedIds`).
374
+ - **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) —
375
+ `navigator.share({ files })` (mobile → save to gallery / send to an app), else individual
376
+ `downloadFileFromUrl` — **never a ZIP**. It shares the BYTES (fetches each URL → `File`), so URL
377
+ expiry afterward is moot; the share path needs the host iframe to grant `allow="web-share"`
378
+ (else it falls back to download). Best for FAST urls (presigned storage). **For SLOW urls (an
379
+ auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
380
+ {credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])`
381
+ runs `navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch
382
+ burns the tap's transient activation**, which silently lands in the download fallback.
383
+ `prepareShareFiles` throws on a fetch failure (log it, don't swallow) and returns `null` when
384
+ Web Share is unavailable (download instead); `shareFiles` returns
385
+ `"shared" | "cancelled" | "unsupported"`.
386
+ - **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation
387
+ into a NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is
388
+ persisted). Pair with `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a
389
+ rotated image).
390
+
391
+ ## Stage gates — tiered by weight
392
+
393
+ A transition that needs NO input is one click. 1–3 quick fields → a POPOVER FORM anchored to its
394
+ action button (title + one-line stake + `FormField`s; confirm in `PopoverFooter`). A
395
+ destructive/exception path (anything touching money or locks) → a `Dialog` (consequence in prose,
396
+ danger confirm + cancel in `DialogFooter`) or an `Alert.alert(title, message, [{cancel},
397
+ {destructive}])` confirm. Never an inline expanding panel for a gate — it shifts layout and loses
398
+ the "this is a gate" framing.