@lotics/ui 41.0.0 → 41.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
package/MIGRATION.md CHANGED
@@ -4,6 +4,29 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 41.1.0 — `FileRows` no longer captions every row with the file type
8
+
9
+ `meta` defaulted to the file-type label, so a row read `contract.pdf` with `PDF` under it. The
10
+ type was already stated three other ways — the `FileBadge`'s colour, the label printed inside
11
+ that glyph, and the filename's own extension — so the caption was a fourth statement of one
12
+ fact, costing a line of row height per file to say nothing.
13
+
14
+ **Every `FileRows` in the repo took this default and passed no `meta`**, so this changes what
15
+ your file lists render: each row loses its second line and gets shorter. No API is removed and
16
+ nothing breaks a type.
17
+
18
+ To keep the old caption, ask for it:
19
+
20
+ ```diff
21
+ - <FileRows files={files} />
22
+ + <FileRows files={files} meta={(f) => resolveMime(f.mimeType).label} />
23
+ ```
24
+
25
+ `resolveMime` is exported from `@lotics/ui/file_badge`.
26
+
27
+ Better: pass something the NAME does not already carry — what an invoice covers, who uploaded
28
+ it, when it landed. That is what a second line is for.
29
+
7
30
  ## 41.0.0 — `RingGauge` is `ProgressRing`, and circular progress is one component
8
31
 
9
32
  `RingGauge` is **deleted**. `ProgressRing` replaces it and covers both what it did and the
package/docs/catalog.md CHANGED
@@ -926,6 +926,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
926
926
  a data-grid cell is `<InlineSelect variant="bare" …/>`, not a `SelectCell` (naming a component for a
927
927
  USE, and duplicating the picker stack to flip two style properties, are both things the kit forbids).
928
928
  Hand-roll a pressable cell to match `bare`, never a background wash.
929
+ - **`inline_files`** — `InlineFiles`: the FILES member of the Inline\* family — a record's
930
+ attachments as a `DetailRow` value. A `FileRows` LIST (press a row to preview full-screen, ⋯
931
+ to remove) with ONE CTA under it; no selection mode, no bulk bar, no upload queue — reach for
932
+ `FilesEditor` when a surface genuinely needs those, which a record row does not. **`multiple`
933
+ is the FIELD's cardinality and it changes the verb**: many → the CTA reads `addMoreLabel` and
934
+ the host APPENDS in `onAdd`; one → it reads `replaceLabel` and the host SETS, because "add
935
+ another" is not an act a single-file field can perform and offering it is how a record ends up
936
+ holding two of something the schema says there is one of (the component owns the affordance,
937
+ it cannot own the persistence). `blockedReason` renders a sentence instead of the CTA for a
938
+ field with nothing to attach TO yet — never a disabled button, which says only that you may
939
+ not. A LIST and not switchable: documents are identified by NAME, images by CONTENT, so a
940
+ photo field wants `FileGrid`/`FilesEditor` instead — that is a judgment about the data, so it
941
+ picks the component rather than setting a prop. Sits on the inline grid's VERTICAL beat with
942
+ NO horizontal inset (unlike `InlineSlot`, which insets 9px to align a raw STRING's baseline):
943
+ its children are boxes, and a box aligns with the editors' box, not with their text.
929
944
  - **`inline_static`** — `InlineStatic`: a READ-ONLY value matching the Inline\* box metrics
930
945
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
931
946
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
@@ -1546,7 +1561,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1546
1561
  `TableRow` 52, this component's own `md` 56), which is what "squeezed" literally was.
1547
1562
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
1548
1563
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
1549
- `FileGalleryModal`; localized via the `gallery` locale slice.
1564
+ `FileGalleryModal`; localized via the `gallery` locale slice. **`meta` has NO default**: the
1565
+ badge states the type as colour and as a label, and the filename ends in the extension, so a
1566
+ type caption was a fourth statement of one fact costing a line of row height. Pass a second
1567
+ line only when it carries something the NAME does not — what an invoice covers, who uploaded
1568
+ it, when it landed.
1550
1569
  - **`file_badge`** — `FileBadge`: the two-tone file-type mark (PDF / XLSX / DOCX / video /
1551
1570
  audio …) driven by MIME. Its `size` is the badge's WIDTH and everything scales off it —
1552
1571
  the mark is TALLER than it is wide (26 × 32 by default), so a square slot takes the
@@ -25,7 +25,8 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
25
25
  | ONE pick from a BIG registry (ports, projects…) | on a RECORD surface: **`InlineSelect searchable`** (search lives in the popover); on a form/attach surface: **`Combobox`** with `value` | an inline row stays an inline editor — a persistent search input breaks the surface's grammar |
26
26
  | a YES/NO on a record | **`CheckboxInput`** as the `DetailRow` value | a boolean needs no editor mode |
27
27
  | a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
28
- | FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery; intake = CTA + drop-anywhere + paste) | add / preview / delete |
28
+ | FILES on a record ROW | [**`InlineFiles`**](#inlinefiles--the-files-member-of-the-inline-family) a list + one CTA, on the inline-control grid | documents are identified by NAME, and the value column stays a column |
29
+ | FILES as a whole SECTION | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery; intake = CTA + drop-anywhere + paste) | add / preview / delete, with select + bulk |
29
30
  | a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
30
31
 
31
32
  **The right-input-per-field law.** A field gets the control its SHAPE wants, never a default
@@ -553,6 +554,48 @@ blue open) with a quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each s
553
554
  dot + label, current marked + disabled), not a generic text menu. A composition (Badge + Button +
554
555
  Popover/MenuButton).
555
556
 
557
+ ## `InlineFiles` — the FILES member of the `Inline*` family
558
+
559
+ A record field whose value is documents. `<DetailRow label="Scan"><InlineFiles … /></DetailRow>`
560
+ — a `FileRows` list (press a row to preview it full-screen, ⋯ to remove) with one CTA under it,
561
+ sitting on the inline-control grid via `InlineSlot`.
562
+
563
+ It exists because every other field type shipped BOTH halves — `Select`/`InlineSelect`,
564
+ `TextInputField`/`InlineTextInput` — and files shipped only the section-scale one. So a record
565
+ field holding documents got hand-rolled per app as `FileRows` + `pickFiles` + a `Button`, and
566
+ each copy re-derived the same decisions. The one they missed is `InlineSlot`: raw children in a
567
+ value column start at the cell edge and 20px tall, beside editors that are 40px with a 9px text
568
+ inset, so the column acquires two left edges and two baselines and stops reading as a column.
569
+
570
+ **Cardinality is a property of the FIELD, and it changes the verb.**
571
+
572
+ | `multiple` | picker | CTA once something is attached | the host's `onAdd` must |
573
+ |---|---|---|---|
574
+ | `true` (default) | many at once | `addMoreLabel` — "Add page" | APPEND |
575
+ | `false` | exactly one | `replaceLabel` — "Replace contract" | **SET** |
576
+
577
+ "Add another" is not an act a single-file field can perform, and offering it produces a field
578
+ holding two of something the schema says there is one of. The component gets the affordance
579
+ right; it cannot enforce the persistence, because it does not own the data — so the difference
580
+ lives at the call site, `onAdd={([f]) => setFile(f)}` against
581
+ `onAdd={(fs) => setFiles([...files, ...fs])}`, where it is decided.
582
+
583
+ **Nothing to attach TO yet is a real state.** Pass `blockedReason` and the CTA is replaced by a
584
+ sentence naming the act that unblocks it ("attach the invoice number first"). Never a disabled
585
+ button: disabled says you may not and leaves the reader to work out why and what would change
586
+ it — same pixels, one of them useful.
587
+
588
+ **A LIST, and not switchable.** Documents are identified by NAME; a PDF/Word/Excel thumbnail is
589
+ a grey page identical to every other grey page, so a grid of them is a wall of one tile under a
590
+ truncated caption. Images are identified by their CONTENT and want `FileGrid`/`FilesEditor`.
591
+ Which one a field holds is a judgment about the DATA, so it picks the component rather than
592
+ setting a prop on this one.
593
+
594
+ Lean by design — no selection mode, no bulk bar, no upload queue. The kit never uploads: the
595
+ host takes `File[]`, stores them, and passes the result back as `files`. Return a promise from
596
+ `onAdd` and the CTA stays busy until it settles, which is the whole loading state — a control
597
+ that re-arms immediately invites the second press that files it twice.
598
+
556
599
  ## Attachments — a full add / preview / DELETE field
557
600
 
558
601
  ### Intake — files land THREE ways, on every surface that holds files
@@ -49,6 +49,7 @@ import { FileDropTarget } from "@lotics/ui/file_drop_target";
49
49
  import { Link } from "@lotics/ui/link";
50
50
  import { TextInputField } from "@lotics/ui/text_input_field";
51
51
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
52
+ import { InlineFiles } from "@lotics/ui/inline_files";
52
53
  import { InlineSelect } from "@lotics/ui/inline_select";
53
54
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
54
55
  import { useSelection } from "@lotics/ui/use_selection";
@@ -361,6 +362,7 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
361
362
  const [ngayNhan, setNgayNhan] = useState(() => (r ? isoReceived(r.ngayNhan) : ""));
362
363
  const [collected, setCollected] = useState(r?.daThu ? "yes" : "no");
363
364
  const [fee, setFee] = useState<number | null>(r?.phi ?? null);
365
+ const [docs, setDocs] = useState<DisplayFile[]>([]);
364
366
  if (!r) return null;
365
367
  return (
366
368
  <>
@@ -380,6 +382,25 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
380
382
  <DetailRow label="Fee">
381
383
  <InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
382
384
  </DetailRow>
385
+ {/* FILES AS A FIELD — the `Inline*` member that used to be missing,
386
+ so a record's documents got hand-rolled per app as `FileRows` +
387
+ `pickFiles` + a `Button`, each copy re-deriving the same
388
+ decisions and usually missing `InlineSlot` (which is what keeps
389
+ this value column a column rather than two left edges).
390
+
391
+ The MANY case: `multiple` defaults true, the CTA becomes
392
+ "Add document" once something is attached, and the host APPENDS.
393
+ `tpl_record` shows the single-file case, where the verb is
394
+ replace — see the table in data_entry.md §InlineFiles. */}
395
+ <DetailRow label="Documents">
396
+ <InlineFiles
397
+ files={docs}
398
+ onAdd={(picked) => setDocs((p) => [...p, ...toDisplayFiles(picked)])}
399
+ onRemove={(f) => setDocs((p) => p.filter((x) => x.id !== f.id))}
400
+ addLabel="Attach document"
401
+ addMoreLabel="Add document"
402
+ />
403
+ </DetailRow>
383
404
  <DetailRow label="Collected">
384
405
  <InlineSelect
385
406
  value={collected}
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
2
- import { ScrollView, View } from "react-native";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
5
  import { DRAWER_GUTTER } from "@lotics/ui/drawer";
@@ -8,6 +8,7 @@ import { BackButton } from "@lotics/ui/back_button";
8
8
  import { Divider } from "@lotics/ui/divider";
9
9
  import { Link } from "@lotics/ui/link";
10
10
  import { Icon } from "@lotics/ui/icon";
11
+ import { TextLink } from "@lotics/ui/text_link";
11
12
  import { Alert } from "@lotics/ui/alert";
12
13
  import type { PickerOption } from "@lotics/ui/picker";
13
14
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
@@ -31,7 +32,9 @@ import { Skeleton } from "@lotics/ui/skeleton";
31
32
  import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
32
33
  import { Composer } from "@lotics/ui/composer";
33
34
  import { IconButton } from "@lotics/ui/icon_button";
35
+ import { FileBadge } from "@lotics/ui/file_badge";
34
36
  import { FileRows } from "@lotics/ui/file_rows";
37
+ import { InlineFiles } from "@lotics/ui/inline_files";
35
38
  import { FileGrid } from "@lotics/ui/file_grid";
36
39
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
40
  import { InlineStatic } from "@lotics/ui/inline_static";
@@ -399,6 +402,19 @@ const PANEL_GUTTER = PANEL_W + RAIL_GAP;
399
402
  * `Drawer`'s 600 it sat between two columns of its own order and stopped reading
400
403
  * as the thing the page is about. */
401
404
  const CONTENT_MAX = 680;
405
+ /** The page's own horizontal padding. Named because `groupW` subtracts it and
406
+ * the panel's fold threshold must add it back — they were written apart, and
407
+ * the threshold omitted it, so the reading column could sit a full 56px UNDER
408
+ * the floor the same code declared. A floor that is not enforced is not a
409
+ * floor; it is a comment. */
410
+ const PAGE_PAD = 28;
411
+ /** What the reading column needs to stay readable beside both gutters — a label
412
+ * column plus its values, not what it would like. Below this the discussion
413
+ * panel FOLDS to an inline section, which is the template's stated rule
414
+ * ("when the two compete the panel is what gives way") finally implemented:
415
+ * the panel's width is a constant, so without a floor on this side the reading
416
+ * column absorbed every squeeze and the rule ran backwards. */
417
+ const CONTENT_FLOOR = 420;
402
418
 
403
419
  // ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
404
420
  // record's documents surface: files feed ONE "Use AI" entry that forks into
@@ -415,6 +431,7 @@ function fmtSize(kb: number): string {
415
431
  if (kb <= 0) return "—";
416
432
  return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
417
433
  }
434
+ const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
418
435
  const MOCK_PHOTO_URL =
419
436
  "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
420
437
  const DOCS: Doc[] = [
@@ -436,6 +453,11 @@ const DOCS: Doc[] = [
436
453
  { 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==" },
437
454
  { 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==" },
438
455
  { 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==" },
456
+ // NOT an image, deliberately: the signed note is evidence of the same hand-over
457
+ // as the frames around it, and it is the tile a fixture of eight JPGs never
458
+ // rendered. `FileThumbnail` shows a doc tile (badge + name) — no code needed,
459
+ // only a fixture honest enough to reach it.
460
+ { id: "p8", name: "bien-ban-giao-nhan-da-ky.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 412, added: "02 Jul", addedAt: 702, url: MOCK_PDF_URL },
439
461
  { 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==" },
440
462
  ];
441
463
 
@@ -607,14 +629,30 @@ const PDF = "application/pdf";
607
629
 
608
630
  /**
609
631
  * A file rendered as a table VALUE — the filename at the size every other cell
610
- * uses.
632
+ * uses. The ONE-FACT cell: the name IS the value, so it is rendered like the
633
+ * quantity beside it and nothing else is added.
611
634
  *
612
635
  * It carried a `FileBadge` at first, which in a cell that size shrank its own
613
636
  * label to five pixels — off every rung of the type scale. Making it legible
614
637
  * means a 34px box setting the height of a 52px row, for a fact the extension
615
- * already carries. The discriminator is what the file IS on the surface: the
616
- * row's SUBJECT (a `FileRow`, a tile) earns a badge; one VALUE among columns is
617
- * a value, rendered like the quantity beside it.
638
+ * already carries.
639
+ *
640
+ * The discriminator is HOW MANY FACTS the cell owes the reader. This cell owes
641
+ * ONE — which document — and the name is it.
642
+ *
643
+ * A cell owing TWO is a different shape and wants a mark this one must not
644
+ * borrow: a reference the desk quotes (an invoice number) PLUS whether the
645
+ * document behind it is on file, where nothing in the value answers the second
646
+ * question. `tpl_item_list`'s register is the worked example — it is a
647
+ * full-width surface with room for the column, which this page is not: measured
648
+ * at 1280 the fee table already shows 3 of its 5 columns, because a record page
649
+ * spends its width on a nav rail and a comments column. A column that always
650
+ * drops teaches nothing, so the rule lives here and the example lives there.
651
+ *
652
+ * The mark is a monochrome clip, NOT a `FileBadge`: a badge's payload beyond
653
+ * presence is the file's TYPE, its label is unreadable at row scale (5px
654
+ * measured), and on a ledger where every document is a PDF it spends a hue on
655
+ * something that never varies.
618
656
  */
619
657
  function DocName({ doc }: { doc: LineDoc }) {
620
658
  return <Text size="sm" numberOfLines={1}>{doc.name}</Text>;
@@ -665,7 +703,6 @@ const FINDINGS: Check[] = [
665
703
  ];
666
704
 
667
705
  // A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
668
- const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
669
706
  const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
670
707
  /** A comment's ThreadFile (snake_case, the API shape) → the kit's DisplayFile. */
671
708
  const commentFileToDisplay = (f: ThreadFile): DisplayFile => ({ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.url ?? "", thumbnailUrl: f.thumbnail_url });
@@ -707,6 +744,16 @@ const iso = (d: Date): string => d.toISOString().slice(0, 10);
707
744
  // display VAT / Invoice no / Note — it drops them silently. And `note` is prose:
708
745
  // it has no column width at any measure.
709
746
  type FeeDirection = "charge" | "cost";
747
+ /** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
748
+ * the stored object; the object URL stands in for that here. */
749
+ let feeDocSeq = 0;
750
+ const asDisplayFile = (f: File): DisplayFile => ({
751
+ id: `fee-doc-u${(feeDocSeq += 1)}`,
752
+ filename: f.name,
753
+ mimeType: f.type || "application/octet-stream",
754
+ url: URL.createObjectURL(f),
755
+ });
756
+
710
757
  interface Fee {
711
758
  id: string;
712
759
  name: string;
@@ -717,25 +764,41 @@ interface Fee {
717
764
  vat: number | null;
718
765
  due: string;
719
766
  invoiceNo: string;
767
+ /** The supplier's original, filed against THIS fee. A number is what the field
768
+ * claimed; these are what actually arrived, and the gap between them is the
769
+ * work a payables desk does. */
770
+ docs: DisplayFile[];
720
771
  paid: boolean;
721
772
  note: string;
722
773
  }
723
774
  let feeSeq = 4;
724
775
  const FEE_SEED: Fee[] = [
725
- { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", paid: true, note: "" },
726
- { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", paid: false, note: "Last-mile to the port" },
727
- { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", paid: true, note: "" },
728
- { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "", paid: false, note: "3 extra days at Central hub" },
776
+ { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
777
+ { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", docs: [{ id: "fee-doc-1", filename: "NH-2044-invoice.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], paid: false, note: "Last-mile to the port" },
778
+ // UNPAID with a number claimed and nothing filed the state the status column
779
+ // exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
780
+ // never render and the design could not be seen at all.
781
+ { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", docs: [], paid: false, note: "" },
782
+ // A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
783
+ // who sends a phone snap has not sent a tax document, and the colour says so
784
+ // before anyone opens it. A fixture of nothing but PDFs makes the badge look
785
+ // like decoration, because the one thing it carries never varies.
786
+ { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "HF-8821", docs: [{ id: "fee-doc-2", filename: "storage-invoice-photo.jpg", mimeType: "image/jpeg", url: MOCK_PHOTO_URL }], paid: false, note: "3 extra days at Central hub" },
729
787
  ];
730
788
  // `priority` = the mobile contract: Party then Type drop first on a narrow
731
789
  // container, Amount + Status survive with the identity; at the floor the rows
732
790
  // STACK label-over-value — and the expansion always carries the full fee.
791
+ // THREE columns, not five — the register's grammar. A column per FACT spends
792
+ // width on separation the reader never asked for: the party belongs to the fee's
793
+ // identity and the payment state belongs to its amount, so each rides line 2 of
794
+ // the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
795
+ // more than the document column costs — the table gained information and lost
796
+ // two columns. `tpl_item_list` stacks both of these already; this table was the
797
+ // outlier.
733
798
  const FEE_COLUMNS: TableColumn[] = [
734
799
  { key: "fee", label: "Fee", flex: 1 },
735
- { key: "type", label: "Type", width: 72, priority: 3 },
736
- { key: "party", label: "Party", width: 150, priority: 4 },
737
800
  { key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
738
- { key: "status", label: "Status", width: 96, priority: 2 },
801
+ { key: "invoice", label: "Invoice", width: 116, priority: 2 },
739
802
  ];
740
803
  const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
741
804
 
@@ -1162,7 +1225,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1162
1225
  const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
1163
1226
  const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
1164
1227
  const addFee = () => {
1165
- const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
1228
+ const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
1166
1229
  setFees((prev) => [...prev, f]);
1167
1230
  setFeeView({ kind: "edit", id: f.id });
1168
1231
  };
@@ -1173,6 +1236,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1173
1236
  ]);
1174
1237
  };
1175
1238
  // The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
1239
+ /**
1240
+ * MONEY state only — it rides under the amount it is about.
1241
+ *
1242
+ * "Awaiting docs" lived here while the document had no column of its own, and
1243
+ * it was a conflation: whether a supplier's original has arrived is not a fact
1244
+ * about the money, and putting it in the money's state meant a fee could not
1245
+ * report both at once. Now the Invoice column carries the document and this
1246
+ * carries the payment, and each says one thing.
1247
+ */
1176
1248
  const feeStatus = (f: Fee): { text: string; danger: boolean } =>
1177
1249
  f.paid ? { text: "Paid", danger: false }
1178
1250
  : feeOverdue(f) ? { text: "Overdue", danger: true }
@@ -1278,6 +1350,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1278
1350
  const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
1279
1351
  const seq = useRef(414);
1280
1352
  const taxId = customer?.taxId ?? "";
1353
+ /** The ONE signed contract — a single-file field, not a document set. */
1354
+ const [contract, setContract] = useState<DisplayFile | null>(null);
1281
1355
  const taxIdValid = TAX_ID_RE.test(taxId);
1282
1356
 
1283
1357
  // ── the document desk (see the module banner above; the record's files ARE
@@ -1286,7 +1360,20 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1286
1360
  // The photo subset of the SAME collection — one list, two surfaces. Derived
1287
1361
  // rather than a second state, or the two sections would drift the moment
1288
1362
  // anything is added to one of them.
1289
- const photoDocs = useMemo(() => files.filter((f) => f.mimeType.startsWith("image/")), [files]);
1363
+ /**
1364
+ * EVERYTHING on the record, not the image subset.
1365
+ *
1366
+ * It filtered `mimeType.startsWith("image/")`, which read well until you asked
1367
+ * what happens to the signed delivery note: it is evidence of the same
1368
+ * hand-over as the photos beside it, and a MIME test filed it somewhere else.
1369
+ * A reader looking for "what came back from the yard" then has to know which
1370
+ * of two lenses holds each artifact — a question about the FILE FORMAT, which
1371
+ * is never the question they actually have.
1372
+ *
1373
+ * `FileThumbnail` already renders a non-image as a doc tile (badge + name), so
1374
+ * nothing here had to change for it to work — only the filter that hid it.
1375
+ */
1376
+ const photoDocs = files;
1290
1377
  /**
1291
1378
  * SELECT MODE — for the grid only, and only because a grid has nowhere free
1292
1379
  * to put a checkbox.
@@ -1934,7 +2021,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1934
2021
  // two gutters. The floor is what the column needs to stay readable (a label
1935
2022
  // column plus its values), not what it would like — hold out for the ceiling and
1936
2023
  // the thread folds away on every screen under ~1220.
1937
- const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 420;
2024
+ //
2025
+ // Every term `groupW` spends must appear here, PAGE_PAD included. It did not,
2026
+ // and the omission ran in the dangerous direction: the check passed while the
2027
+ // column was under its floor, so the panel kept its constant 300 and the record
2028
+ // — the thing the page is about — took the loss. The two lines are one
2029
+ // calculation and have to be read as one.
2030
+ const wide = pageWidth != null && pageWidth >= PAGE_PAD * 2 + GUTTER + PANEL_GUTTER + CONTENT_FLOOR;
1938
2031
  // Narrow: the discussion is one tap off the same bar the sections are on —
1939
2032
  // on the RIGHT, opposite the section picker, since it is about the record as a
1940
2033
  // whole rather than a place in it.
@@ -1947,8 +2040,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
1947
2040
  // record scrolls. (Capping the ScrollView at the column's width instead makes
1948
2041
  // the gutters dead zones.) Mirrors the flex maths the row does below: the
1949
2042
  // group is centred inside the 28px content padding.
1950
- const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
1951
- const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
2043
+ const groupW = pageWidth == null ? 0 : Math.min(pageWidth - PAGE_PAD * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
2044
+ const railLeft = pageWidth == null ? 0 : PAGE_PAD + Math.max(0, (pageWidth - PAGE_PAD * 2 - groupW) / 2);
1952
2045
  const panelRight = railLeft;
1953
2046
 
1954
2047
  /**
@@ -2379,6 +2472,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2379
2472
  <DetailRow label="Opened">
2380
2473
  <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
2381
2474
  </DetailRow>
2475
+ {/* FILES AS A FIELD, the SINGLE case — one signed contract, not
2476
+ a document set.
2477
+ `multiple={false}` changes the verb, and the verb is the
2478
+ point: "add another" is not something a one-file field can
2479
+ do, and offering it produces a record holding two of a thing
2480
+ the schema says there is one of. So the CTA reads REPLACE,
2481
+ the picker takes one, and the host SETS instead of appending
2482
+ — the component gets the affordance right but cannot enforce
2483
+ the persistence, which is why the difference is visible right
2484
+ here at the call site.
2485
+
2486
+ `blockedReason` is the third state: a field with nothing to
2487
+ attach TO yet says which act unblocks it, rather than showing
2488
+ a disabled button that says only that you may not. */}
2489
+ <DetailRow label="Signed contract">
2490
+ <InlineFiles
2491
+ files={contract === null ? [] : [contract]}
2492
+ multiple={false}
2493
+ onAdd={([picked]) => setContract({ id: "doc-contract", filename: picked.name, mimeType: picked.type || PDF, url: URL.createObjectURL(picked) })}
2494
+ onRemove={() => setContract(null)}
2495
+ addLabel="Attach contract"
2496
+ replaceLabel="Replace contract"
2497
+ accept="application/pdf"
2498
+ blockedReason={taxId.trim() === "" ? "Add the tax ID before filing a contract." : undefined}
2499
+ />
2500
+ </DetailRow>
2382
2501
  {/* The countdown ANNOTATES the date it is about — the worked
2383
2502
  example for @lotics/ui/deadline. It replaced a hand-rolled
2384
2503
  `error={overdue ? … }`, which was binary: a date due
@@ -2689,20 +2808,39 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2689
2808
  its NAME: a PDF thumbnail is a grey page identical to every other grey
2690
2809
  page, so a grid of them is a wall of one tile. A photo is identified
2691
2810
  by its CONTENT — `IMG_4471.jpg` tells nobody anything — so the tile IS
2692
- the identity and a grid puts twelve on screen at once. Same record,
2693
- same verbs, different surface, decided by what the reader recognises.
2811
+ the identity and a grid puts twelve on screen at once.
2694
2812
  (AGENTS.md: documents get a LIST, images get a GRID.)
2695
2813
 
2696
- READ THIS BEFORE COPYING THE SHAPE. Two sections here does NOT mean
2697
- "sort one pile by mime type into a list and a grid". This record holds
2698
- two distinct SETS the paperwork that arrived on it, and the hand-over
2699
- photos taken against it which the reader asks different questions of.
2700
- A SINGLE pile picks ONE surface by its majority and keeps it: the odd
2701
- .docx among forty phone photos stays in the grid, where it renders a
2702
- `DocumentCard` printing its filename, and the odd photo among forty
2703
- PDFs stays in the list, where its row carries the full name and a press
2704
- opens the gallery. Splitting one set by type would give the reader two
2705
- places to look for the same thing.
2814
+ BOTH SURFACES SHOW THE WHOLE COLLECTION they are two LENSES over
2815
+ one set, not two sets. Files answers "what is on this record" in a
2816
+ scannable column; Photos answers "what did it LOOK like" at a glance.
2817
+ A reader who guesses wrong still finds the artifact instead of
2818
+ concluding it was never filed, and nobody has to know a file's FORMAT
2819
+ before they know where to look for it.
2820
+
2821
+ The grid used to filter to `image/*`. That made the split a fact about
2822
+ MIME rather than about reading, and it put the signed delivery note in
2823
+ a different section from the photos of the same hand-over — while this
2824
+ very comment promised that the odd `.docx` among forty phone photos
2825
+ would stay in the grid. It could not: the filter forbade what the
2826
+ sentence described. `FileThumbnail` already renders a non-image as a
2827
+ doc tile (badge + filename), so nothing had to be built — only the
2828
+ filter that hid it had to go.
2829
+
2830
+ THE COST IS REAL AND DELIBERATE: every file now appears twice. The
2831
+ trade is that duplication is cheap to skim past, whereas an artifact
2832
+ filed under the wrong lens looks like an artifact that was never
2833
+ filed. A record whose attachments are overwhelmingly paperwork should
2834
+ drop the grid entirely rather than carry a wall of identical tiles —
2835
+ the choice is per-record-TYPE, made once, not per file.
2836
+
2837
+ Ordering follows the collection, so a record heavy in documents leads
2838
+ this grid with grey tiles. If that reads badly for your record type,
2839
+ sort images first rather than reinstating the filter.
2840
+
2841
+ The tiles are mixed by construction now, so the FIXTURE has to be
2842
+ mixed too — a grid of eight JPGs never once renders the doc tile it
2843
+ has always been able to render, which is how the gap survived.
2706
2844
 
2707
2845
  The ADD rides the heading row, right edge, exactly as it does on Files
2708
2846
  and Fees — a section's add sits in the same spot whether the section
@@ -2777,7 +2915,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2777
2915
  title="Add photos"
2778
2916
  color="primary"
2779
2917
  onPress={() => {
2780
- void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
2918
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
2781
2919
  }}
2782
2920
  />
2783
2921
  )}
@@ -3058,6 +3196,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3058
3196
  <DetailRow label="Invoice no" description="The party's invoice or debit note">
3059
3197
  <InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
3060
3198
  </DetailRow>
3199
+ {/* THE FILE, ON THE ROW THAT OWES IT — the shape a
3200
+ payables desk actually works in: a table of
3201
+ charges, each row expanding to the document that
3202
+ justifies it. The register cell above can only
3203
+ MARK whether a document is held (and on this page
3204
+ not even that — the fee table measures 400px and
3205
+ its declared columns already total 422, so two
3206
+ drop before the flex column gets a pixel). The
3207
+ managing happens here.
3208
+
3209
+ `multiple`, because a supplier invoice arrives as
3210
+ one PDF or as three scanned pages, and the second
3211
+ page must not replace the first.
3212
+
3213
+ `blockedReason` when no number has been claimed:
3214
+ there is nothing to file the scan AGAINST yet, and
3215
+ a sentence naming that act beats a disabled button
3216
+ that only says no. */}
3217
+ <DetailRow label="Supplier original">
3218
+ <InlineFiles
3219
+ files={f.docs}
3220
+ onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
3221
+ onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
3222
+ addLabel="Attach original"
3223
+ addMoreLabel="Add page"
3224
+ accept="application/pdf,image/*"
3225
+ blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
3226
+ />
3227
+ </DetailRow>
3061
3228
  <DetailRow label="Status">
3062
3229
  <InlineSelect
3063
3230
  value={f.paid ? "paid" : "unpaid"}
@@ -3096,11 +3263,51 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3096
3263
  </View>
3097
3264
  }
3098
3265
  >
3099
- <TableCell><Text size="sm" weight="medium" numberOfLines={1}>{f.name || ""}</Text></TableCell>
3100
- <TableCell><Text size="sm" color="muted">{f.direction === "charge" ? "Charge" : "Cost"}</Text></TableCell>
3101
- <TableCell><Text size="sm" color="muted" numberOfLines={1}>{f.party || "—"}</Text></TableCell>
3102
- <TableCell><Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text></TableCell>
3103
- <TableCell><Text size="sm" color={st.danger ? "danger" : "muted"} tabular numberOfLines={1}>{st.text}</Text></TableCell>
3266
+ {/* IDENTITY over its PARTY `tpl_item_list`'s customer cell
3267
+ exactly: the thing you scan for on line 1, what qualifies
3268
+ it on line 2, in one cell rather than two columns. */}
3269
+ <TableCell>
3270
+ <View style={{ gap: 2 }}>
3271
+ <Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
3272
+ <Text size="xs" color="muted" numberOfLines={1}>
3273
+ {f.party || (f.direction === "charge" ? "Charge" : "Cost")}
3274
+ </Text>
3275
+ </View>
3276
+ </TableCell>
3277
+ {/* AMOUNT over its PAYMENT state — the register's fee cell,
3278
+ same stack, same rungs. The state qualifies the number it
3279
+ sits under, so it needs no column and no repeated label. */}
3280
+ <TableCell>
3281
+ <View style={{ gap: 2, alignItems: "flex-end" }}>
3282
+ <Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
3283
+ <Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
3284
+ </View>
3285
+ </TableCell>
3286
+ {/* THE DOCUMENT, with a column of its own at last.
3287
+ Held: a clip + the number, pressable to preview. Claimed
3288
+ but not arrived: the number in WARNING tone — the fee
3289
+ cannot be paid, and that now reads in the column the fact
3290
+ belongs to instead of borrowing the money's state. Never
3291
+ claimed (cash at the gate): an em dash, because nothing is
3292
+ owed and a permanent warning on every such row trains the
3293
+ eye straight past the column. */}
3294
+ <TableCell>
3295
+ {f.invoiceNo.trim() === "" ? (
3296
+ <Text size="sm" color="muted">—</Text>
3297
+ ) : f.docs.length === 0 ? (
3298
+ <Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
3299
+ ) : (
3300
+ <Pressable
3301
+ onPress={() => openPreview(DOCS, 0)}
3302
+ accessibilityRole="button"
3303
+ accessibilityLabel={`Open invoice ${f.invoiceNo}`}
3304
+ style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
3305
+ >
3306
+ <FileBadge mimeType={f.docs[0].mimeType} size={22} />
3307
+ <TextLink size="sm">{f.invoiceNo}</TextLink>
3308
+ </Pressable>
3309
+ )}
3310
+ </TableCell>
3104
3311
  </TableRow>
3105
3312
  );
3106
3313
  })}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "41.0.0",
3
+ "version": "41.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -153,6 +153,7 @@
153
153
  "./inline_edit": "./src/inline_edit.tsx",
154
154
  "./inline_slot": "./src/inline_slot.tsx",
155
155
  "./inline_static": "./src/inline_static.tsx",
156
+ "./inline_files": "./src/inline_files.tsx",
156
157
  "./inline_text_input": "./src/inline_text_input.tsx",
157
158
  "./inline_number_input": "./src/inline_number_input.tsx",
158
159
  "./inline_select": "./src/inline_select.tsx",
@@ -48,7 +48,11 @@ export interface AgentRunLike {
48
48
  * the wrong document, and the only exit was to sit through it.
49
49
  */
50
50
  cancel: () => void;
51
- error: string | null;
51
+ /** The breaking error that killed the run. Accepts `undefined` as well as
52
+ * `null` so an SDK run — whose `error` is an OPTIONAL property — satisfies
53
+ * this without the caller reshaping the object the SDK docs say to hand
54
+ * straight over. The pane normalizes both to absent. */
55
+ error: string | null | undefined;
52
56
  }
53
57
 
54
58
  export function AgentRunScope({ children }: { children: ReactNode }) {
package/src/file_rows.tsx CHANGED
@@ -10,7 +10,6 @@ import { Alert } from "./alert";
10
10
  import { FileRow } from "./file_row";
11
11
  import { ActionMenu, type ActionMenuItem } from "./action_menu";
12
12
  import { FileGalleryModal } from "./file_gallery_modal";
13
- import { resolveMime } from "./file_badge";
14
13
  import { downloadFileFromUrl } from "./download";
15
14
  import { type GalleryLabels } from "./file_preview_types";
16
15
  import { useLoticsLocale } from "./locale";
@@ -18,7 +17,18 @@ import type { DisplayFile } from "./file_thumbnail";
18
17
 
19
18
  export interface FileRowsProps {
20
19
  files: DisplayFile[];
21
- /** Secondary line per file. Default: the file-type label (e.g. "PDF"). */
20
+ /**
21
+ * Secondary line per file. Default: NONE.
22
+ *
23
+ * It defaulted to the file-type label, which every row already states twice —
24
+ * the `FileBadge` encodes the type as its colour AND prints it inside the
25
+ * glyph, and the filename ends in the extension. A fourth statement of one
26
+ * fact is not a caption, it is noise, and it cost every consumer a line of
27
+ * row height to say nothing.
28
+ *
29
+ * Pass something the NAME does not already carry — what an invoice covers, who
30
+ * uploaded it, when it landed.
31
+ */
22
32
  meta?: (file: DisplayFile) => string | undefined;
23
33
  /** Adds a (confirmed) "Remove" to each ⋯ menu and the gallery toolbar. The host
24
34
  * drops the file from its own state. */
@@ -84,7 +94,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
84
94
  <FileRow
85
95
  key={file.id}
86
96
  name={file.filename}
87
- meta={meta ? meta(file) : resolveMime(file.mimeType).label}
97
+ meta={meta ? meta(file) : undefined}
88
98
  mimeType={file.mimeType}
89
99
  onPress={() => setActiveIndex(index)}
90
100
  trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
@@ -0,0 +1,219 @@
1
+ import { useCallback, useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Text } from "./text";
5
+ import { FileRows } from "./file_rows";
6
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
7
+ import { pickFiles } from "./file_picker";
8
+ import { type DisplayFile } from "./file_thumbnail";
9
+ import { type GalleryLabels } from "./file_preview_types";
10
+ import { useLoticsLocale } from "./locale";
11
+
12
+ export interface InlineFilesProps {
13
+ /** What is attached now. */
14
+ files: DisplayFile[];
15
+ /**
16
+ * The user picked these. The HOST uploads them and persists the result — this
17
+ * kit never talks to a server, so `files` only changes when the host says so.
18
+ *
19
+ * **`multiple` decides what the host must DO with them**: appending on a
20
+ * single-file field is how a field that holds one contract quietly ends up
21
+ * holding three. See that prop.
22
+ *
23
+ * Return a promise and the CTA stays busy until it settles, which is the whole
24
+ * of this component's loading state: a picked file is in flight for as long as
25
+ * the host takes, and a control that re-arms immediately invites the second
26
+ * press that files it twice.
27
+ *
28
+ * Omit for a read-only field — the same list, no CTA.
29
+ */
30
+ onAdd?: (picked: File[]) => void | Promise<void>;
31
+ /** Adds a (confirmed) Remove to each row's ⋯ menu. Omit for add-only. */
32
+ onRemove?: (file: DisplayFile) => void;
33
+ /** Open outside the app — a new tab on the frontend, `openExternal` in a
34
+ * sandboxed app. Omit and the row still previews in place. */
35
+ onOpenExternal?: (file: DisplayFile) => void;
36
+ /** Override the download (the host frontend sends `credentials`). */
37
+ onDownload?: (file: DisplayFile) => void;
38
+ /**
39
+ * **The FIELD's cardinality, not the picker's convenience.** Default `true`.
40
+ *
41
+ * `true` — a set. The picker takes several at once, and the CTA reads
42
+ * `addMoreLabel` once something is attached, because adding a second page to a
43
+ * document set is an ordinary act.
44
+ *
45
+ * `false` — exactly one. The picker takes one, and the CTA reads
46
+ * `replaceLabel`, because "add another" is not a thing a single-file field can
47
+ * do: the honest verb is replace, and offering "add" there produces a field
48
+ * holding two of something the schema says there is one of. **The host must
49
+ * then SET rather than append in `onAdd`** — the component cannot enforce it
50
+ * (it does not own the data), and the failure is silent, so the call site
51
+ * reads `onAdd={([f]) => setFile(f)}` against `onAdd={(fs) => setFiles([...files, ...fs])}`
52
+ * and the difference is visible where it is decided.
53
+ *
54
+ * This is a fact about the field, which is why it is a prop and the LIST shape
55
+ * is not: one file or many, the reader still identifies a document by its name.
56
+ */
57
+ multiple?: boolean;
58
+ /**
59
+ * CTA words for the three states — nothing attached, adding to a set,
60
+ * replacing the one.
61
+ *
62
+ * Three, because they are three different acts and a generic "Add files"
63
+ * misleads in two of them. Name them for the domain ("Attach original" /
64
+ * "Add page" / "Replace contract") and the field stops needing a caption to
65
+ * explain itself. Each falls back to the one before it, so a single-file field
66
+ * that never says `replaceLabel` still reads sensibly.
67
+ */
68
+ addLabel?: string;
69
+ addMoreLabel?: string;
70
+ replaceLabel?: string;
71
+ /** Replaces the CTA's words while `onAdd` is in flight. Omit and the CTA keeps
72
+ * its words and simply disables — no invented English in a localized app. */
73
+ busyLabel?: string;
74
+ /**
75
+ * Renders this sentence INSTEAD of the CTA — for a field with nothing to
76
+ * attach TO yet (a scan whose invoice does not exist; a line before its order
77
+ * is saved).
78
+ *
79
+ * Deliberately not a disabled button. A disabled control states that you may
80
+ * not, and leaves the reader to work out why and what would change it; a
81
+ * sentence states the act that unblocks it. Same pixels, one of them useful.
82
+ */
83
+ blockedReason?: string;
84
+ /** Shown when nothing is attached AND there is no CTA. Default "—". */
85
+ emptyLabel?: string;
86
+ /** Native accept filter, e.g. `"application/pdf,image/*"`. */
87
+ accept?: string;
88
+ /** Translated gallery/menu chrome, forwarded to `FileRows`. */
89
+ labels?: Partial<GalleryLabels>;
90
+ /** Credentials mode for the preview fetches — `"include"` for auth-gated
91
+ * proxy URLs, omitted for an app's presigned URLs. */
92
+ credentials?: RequestCredentials;
93
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
94
+ }
95
+
96
+ /**
97
+ * The FILES member of the `Inline*` family — a record's attachments, editable in
98
+ * the value column of a `DetailRow`.
99
+ *
100
+ * Every other field type had both halves already (`Select`/`InlineSelect`,
101
+ * `TextInputField`/`InlineTextInput`); files had only the section-scale one
102
+ * (`FilesEditor`), so a record FIELD holding documents was hand-rolled per app as
103
+ * `FileRows` + a picker + a button. Each copy re-derived the same decisions —
104
+ * including the alignment one, which has a counter-intuitive answer: this field
105
+ * sits on the inline grid's VERTICAL beat but takes NO horizontal inset, because
106
+ * its children are boxes rather than text (see the comment on the wrapper).
107
+ *
108
+ * A LIST, not a grid, and not switchable: documents are identified by NAME — a
109
+ * PDF/Word/Excel thumbnail is a grey page identical to every other grey page — so
110
+ * a grid of them is a wall of one tile under a truncated caption. Images are
111
+ * identified by their CONTENT and want `FileGrid` / `FilesEditor` instead. Which
112
+ * one a field holds is a judgment about the DATA, so it picks the component, not
113
+ * a prop on this one.
114
+ *
115
+ * Lean on purpose: no selection mode, no bulk bar, no upload queue. It shows,
116
+ * previews, adds and removes. Reach for `FilesEditor` when a surface genuinely
117
+ * needs multi-select and bulk download — a record row does not.
118
+ */
119
+ export function InlineFiles(props: InlineFilesProps) {
120
+ const {
121
+ files,
122
+ onAdd,
123
+ onRemove,
124
+ onOpenExternal,
125
+ onDownload,
126
+ multiple = true,
127
+ addLabel,
128
+ addMoreLabel,
129
+ replaceLabel,
130
+ busyLabel,
131
+ blockedReason,
132
+ emptyLabel = "—",
133
+ accept,
134
+ labels,
135
+ credentials,
136
+ onError,
137
+ } = props;
138
+
139
+ const loc = useLoticsLocale().filesEditor;
140
+ const [busy, setBusy] = useState(false);
141
+
142
+ /**
143
+ * Single-flight, released in `finally`.
144
+ *
145
+ * The picker is INSIDE the guard, not around it: a second press while the
146
+ * dialog is open would open a second dialog, and both would resolve into
147
+ * `onAdd`. Cancelling still settles (`pickFiles` resolves `[]`), so the guard
148
+ * releases without the host being called.
149
+ */
150
+ const add = useCallback(async () => {
151
+ if (busy || onAdd === undefined) return;
152
+ setBusy(true);
153
+ try {
154
+ const picked = await pickFiles({ accept, multiple });
155
+ if (picked.length === 0) return;
156
+ await onAdd(picked);
157
+ } finally {
158
+ setBusy(false);
159
+ }
160
+ }, [busy, onAdd, accept, multiple]);
161
+
162
+ const base = addLabel ?? loc.upload;
163
+ const cta =
164
+ files.length === 0
165
+ ? base
166
+ : multiple
167
+ ? (addMoreLabel ?? base)
168
+ : (replaceLabel ?? base);
169
+
170
+ return (
171
+ /* The inline grid's VERTICAL contract only — deliberately not `InlineSlot`.
172
+ *
173
+ * That component pads 8px + a 1px border so a raw STRING's baseline lands
174
+ * where an editor's text does, which is right for text and wrong here: these
175
+ * children are BOXES (file rows, a button), and a box aligns with the
176
+ * editors' box, not with their text. Measured, `InlineSlot` pushed this
177
+ * field 9px right of every input above it — one value column, two left
178
+ * edges, which is the exact defect the slot exists to prevent, arriving
179
+ * through the slot itself.
180
+ *
181
+ * The LIST fills the column and the CTA does not, because they are different
182
+ * kinds of thing: a file row is this field's VALUE and every other value in
183
+ * the column (a text input, a select) spans it, so a short row would read as
184
+ * a different column; the button is an ACTION, and a full-bleed one reads as
185
+ * the section's rather than this field's. Hence stretch by default, with the
186
+ * CTA opting out via `alignSelf` rather than the container shrinking both.
187
+ */
188
+ <View style={{ minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" }}>
189
+ <View style={{ gap: 8, width: "100%" }}>
190
+ {files.length > 0 ? (
191
+ <FileRows
192
+ files={files}
193
+ onRemove={onRemove}
194
+ onOpenExternal={onOpenExternal}
195
+ onDownload={onDownload}
196
+ onError={onError}
197
+ labels={labels}
198
+ credentials={credentials}
199
+ />
200
+ ) : null}
201
+ {blockedReason !== undefined ? (
202
+ <Text size="sm" color="muted">{blockedReason}</Text>
203
+ ) : onAdd !== undefined ? (
204
+ <View style={{ alignSelf: "flex-start" }}>
205
+ <Button
206
+ title={busy ? (busyLabel ?? cta) : cta}
207
+ icon="paperclip"
208
+ color="secondary"
209
+ disabled={busy}
210
+ onPress={() => void add()}
211
+ />
212
+ </View>
213
+ ) : files.length === 0 ? (
214
+ <Text size="sm" color="muted">{emptyLabel}</Text>
215
+ ) : null}
216
+ </View>
217
+ </View>
218
+ );
219
+ }