@lotics/ui 15.0.0 → 15.1.1

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` for a custom-code app's `vite.config.ts`). 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, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments, stage gates. |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
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). |
@@ -27,6 +27,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
27
27
  license to hand-roll.
28
28
  - **One canonical component per data role** (member → `MemberChip`, select → `OptionBadge`,
29
29
  files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
30
+ - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
31
+ attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
32
+ never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
33
+ inverting the hierarchy the row is scanned by. →
34
+ [composition.md §`Badge` is for STATUS only](./docs/composition.md).
30
35
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
31
36
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
32
37
  - **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
@@ -36,6 +41,15 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
36
41
  (the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
37
42
  button. `Table`/`TableRow` and `LinkedRecordBox` already do this. →
38
43
  [composition.md](./docs/composition.md).
44
+ - **Files land THREE ways on any surface that holds them** — the section's Add CTA
45
+ (`pickFiles`), and a drag OR paste, both from ONE **`<FileDropTarget … paste>`** wrapping the
46
+ region — a Files section, OR a WHOLE surface (a record's section stack, a register page), so a
47
+ drop/paste anywhere on it routes to that surface's intake (its `paste` scopes Ctrl/Cmd+V to the
48
+ region's focus; `usePasteFiles` is the bare hook for a region-less modal), all on ONE handler.
49
+ Paste routes to the top-most FOCUSED region, else the top of the stack — two peer file sections
50
+ each win when focus is in them. Nobody should hunt for a dropzone; a `FileDropzone` well is the
51
+ empty state, not the only way in. Since a wrapped region shows no zone, name the paths with a
52
+ muted heading description ("Drag, paste, or click to add files"). → [data_entry.md](./docs/data_entry.md).
39
53
  - **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
40
54
  - **Copy states content, not mechanics** — a `description` names the section's content or one
41
55
  domain rule; widget behavior ("edits in place, saves on its own") and selling points never
package/MIGRATION.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @lotics/ui — migration notes
2
2
 
3
- Breaking changes per major, newest first. The current contract lives in `AGENTS.md` + `docs/`;
4
- this file exists only to move an app from one major to the next.
3
+ Breaking changes, newest first — normally per major, plus the rare minor that broke a type
4
+ anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
+ this file exists only to move an app from one release to the next.
5
6
 
6
7
  ## v15 from 14.x
7
8
 
@@ -12,6 +13,27 @@ just the name, so labels can vary by input and by the new **`awaiting`** step st
12
13
  an expanded step's Output panel), `onRetry` (a retry button under the terminal error row), and
13
14
  the new `ApprovalPrompt` + `MessageActions` components with their locale slices.
14
15
 
16
+ ## v14 from 13.x
17
+
18
+ The ai-sdk `UIMessage.parts` shape IS the `AgentRun` contract — the bespoke transcript type is
19
+ gone. `AgentRun`/`AgentProgress`'s **`items: AgentRunItem[]`** was replaced by **`parts:
20
+ readonly AgentUIPart[]`**, and the **`AgentRunItem`** / **`AgentRunStep`** / **`AgentStepStatus`**
21
+ exports were deleted. An app feeds it straight from the SDK: `useAgentRun().items` →
22
+ **`.parts`** (`@lotics/app-sdk` ≥ 0.54.0, whose SSE reducer now emits ai `dynamic-tool` / `text`
23
+ / `reasoning` parts); chat passes `message.parts`. Hand-built demo transcripts are rewritten as
24
+ parts. Additively: a run-level **`error`** prop for the BREAKING failure that lives OUTSIDE
25
+ `parts` (a killed stream / API error) — a per-tool failure stays in its own `output-error` part
26
+ — and tool I/O now expands IN PLACE on press instead of opening a `Peek` popover.
27
+
28
+ ## 13.9.0 — a BREAKING type change in a MINOR
29
+
30
+ **`ClarifyOption.description` went optional → REQUIRED** (every answer explains itself). It
31
+ shipped in a minor, which is why it surprises an app on `^13`: a `<Clarify options={…}>` whose
32
+ options carry only `label` stops typechecking on any 13.9.0+ install. Fix by writing the
33
+ one-line description each option always needed. Same release: `Clarify`'s hardcoded "Question"
34
+ eyebrow was dropped (the question text stays), and `allowCustom` ("Other…" free text) +
35
+ `ClarifyWizard` were added.
36
+
15
37
  ## v13 from 12.x
16
38
 
17
39
  `RadioPicker`'s **`direction`** prop was removed — the picker is always the stacked column of
package/docs/catalog.md CHANGED
@@ -225,7 +225,11 @@ tile a pressable door — e.g. tap-to-preview — carrying an accessible button
225
225
  filename as its name, overridable per tile with `accessibilityLabel` when what the press
226
226
  DOES reads better than a raw filename; `selectedIds` for a selection overlay),
227
227
  `UploadingThumbnail` (the single in-flight tile FileGrid renders — reach for it only when
228
- hand-rolling a non-grid upload layout), `FileRow` (a horizontal file/document LINE
228
+ hand-rolling a non-grid upload layout), `FileDropTarget` (wrap ANY region a record's Files
229
+ section, a card, a panel — so a file dragged onto it lands; `paste` also takes Ctrl/Cmd+V
230
+ scoped to that region's focus; the region-wrapper next to `FileDropzone`'s dedicated well) +
231
+ `usePasteFiles` (the bare clipboard hook underneath, for a region-less surface) — the halves of
232
+ "files land without hunting for a dropzone", `FileRow` (a horizontal file/document LINE —
229
233
  badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge /
230
234
  action / remove; `onPress` makes the whole row a pressable door, `trailing` stays an
231
235
  independently-pressable sibling; for checklists & readable lists), `FileBadge` (the two-tone
@@ -905,9 +909,35 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
905
909
  - **`file_picker`** — `pickFiles({ accept?, multiple? }) → Promise<File[]>`: opens the
906
910
  browser file dialog imperatively — the trigger half behind every Add-file CTA; cancel
907
911
  resolves `[]` on modern engines.
908
- - **`file_dropzone`** — `FileDropzone`: the drag-drop capture well (`onFiles`, `accept`,
909
- `label`/`hint`/`dropLabel`, `height`); click falls back to a picker. `label`/`dropLabel`
910
- default from the locale's `fileDropzone` slice pass them only to override.
912
+ - **`file_dropzone`** — `FileDropzone`: the dedicated drag-drop capture well (`onFiles`,
913
+ `accept`, `label`/`hint`/`dropLabel`, `height`); click falls back to a picker, and while
914
+ mounted it ALSO takes a paste (Ctrl/Cmd+V) via its inner `FileDropTarget` `paste`, scoped to
915
+ the well's region. `label`/`dropLabel`/`hint` default from the locale's `fileDropzone` slice
916
+ (the hint now names the paste) — pass them only to override.
917
+ - **`file_drop_target`** — `FileDropTarget`: makes ANY region accept a file drag-drop —
918
+ `{ onFiles, accept?, multiple?, paste?, disabled?, children, style? }` wrapped around a
919
+ section, a card, a whole panel, so nobody hunts for a dropzone. While a drag hovers, the
920
+ region wears the kit's accent ring + wash (a box-shadow — nothing reflows); pass a FUNCTION
921
+ child (`(dragging) => …`) to draw the drag state yourself and the target paints nothing (how
922
+ `FileDropzone` keeps its well styling). **`paste`** ALSO takes Ctrl/Cmd+V, wiring
923
+ `usePasteFiles` scoped to THIS region's focus — so two peer file sections each win when
924
+ focus is in them, instead of the last-mounted one grabbing every paste. Native renders the
925
+ children unchanged.
926
+ - **`use_paste_files`** — `usePasteFiles({ onFiles, accept?, multiple?, enabled?, region? })`:
927
+ the clipboard half of intake — Ctrl/Cmd+V adds a copied file or a screenshot to the open
928
+ surface (most reach it via `FileDropTarget`'s `paste`; use the bare hook for a region-less
929
+ surface like a modal dialog). Scope it with `enabled` ("while this dialog/record is open"); a
930
+ paste carrying no acceptable file is left alone, so typing Ctrl+V in a text field still pastes
931
+ text. **Routing (`region`):** among all enabled sinks, the paste goes to the TOP-MOST whose
932
+ `region` contains `document.activeElement`; with none focused it falls back to the top of the
933
+ stack (the last-mounted surface — right for a dialog over a screen). A sink with no `region`
934
+ is a pure stack participant, so a single existing consumer is unchanged. Native is a no-op.
935
+ - **`file_intake`** — the shared, RN-free intake core + the contracts above:
936
+ `matchesAccept(file, accept)`, `filesFromTransfer(dataTransfer, { accept?, multiple? })`
937
+ (one extractor for BOTH `drop` and `paste`, with the clipboard-`items` fallback a pasted
938
+ screenshot needs), `selectPasteSink(entries)` (the pure focus-then-stack routing rule the
939
+ `.web` sink applies), and the types `FileIntakeFilter` / `FileTransferLike` / `RegionRef` /
940
+ `PasteSinkEntry` / `UsePasteFilesOptions` / `FileDropTargetProps`.
911
941
  - **`files_editor`** — `FilesEditor` — THE all-in-one attachment field: `FileGrid` + a
912
942
  toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
913
943
  (Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only,
@@ -24,7 +24,7 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
24
24
  | 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 |
25
25
  | a YES/NO on a record | **`CheckboxInput`** as the `DetailRow` value | a boolean needs no editor mode |
26
26
  | a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
27
- | FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery) | add / preview / delete |
27
+ | FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery; intake = CTA + drop-anywhere + paste) | add / preview / delete |
28
28
  | a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
29
29
 
30
30
  **The right-input-per-field law.** A field gets the control its SHAPE wants, never a default
@@ -340,6 +340,56 @@ Popover/MenuButton).
340
340
 
341
341
  ## Attachments — a full add / preview / DELETE field
342
342
 
343
+ ### Intake — files land THREE ways, on every surface that holds files
344
+
345
+ A user should never hunt for a dropzone. Any surface that holds files carries all three
346
+ intake paths, all wired to ONE handler:
347
+
348
+ 1. **The section's own CTA** — `pickFiles` behind an Add-file button (the explicit path).
349
+ 2. **Drop anywhere on the region + paste** — wrap the region in
350
+ **`<FileDropTarget onFiles accept multiple paste disabled style>`**. It makes ARBITRARY
351
+ children a drop region: drag a file over any part of it and the region wears the accent ring
352
+ + wash (a box-shadow, so nothing reflows), drop and `onFiles` fires. Add **`paste`** and the
353
+ SAME region also takes Ctrl/Cmd+V (a screenshot or a copied file) — so the region carries
354
+ both non-explicit paths from one wrapper. This is the region-wrapper; `FileDropzone` stays
355
+ the dedicated empty-state WELL (and is itself a `FileDropTarget` with `paste` — a function
356
+ child, `(dragging) => …`, lets a child own the drag visual and the target paint nothing).
357
+ **The region can be a WHOLE SURFACE, not just a Files section.** Wrap a record's entire
358
+ section stack (or a register's whole page) in ONE `FileDropTarget` and a drop/paste ANYWHERE
359
+ on it routes to that surface's intake — the user never has to find the Files section. Give
360
+ `flex:1` (+ `minWidth:0` inside a flex row) so the wrapper is layout-transparent, and
361
+ `disabled` while an intake dialog is open so a paste can't start a second one. When the whole
362
+ surface is the target, the inner section drops its OWN `FileDropTarget` (redundant — one drop
363
+ would otherwise double it); its affordance line stays.
364
+ 3. **The bare paste hook** — **`usePasteFiles({ onFiles, accept, multiple, enabled, region })`**
365
+ is what `paste` wires internally; reach for it directly only for a region-LESS surface (a
366
+ modal dialog with no wrapper). Scope it with `enabled` ("while this record/dialog is open");
367
+ a paste with no acceptable file is left alone, so Ctrl+V in a text field still pastes text.
368
+
369
+ **Paste routing (focus, then stack).** When two file targets are enabled on the same layer,
370
+ the paste goes to the TOP-MOST one whose `region` contains focus (`document.activeElement`) —
371
+ so two peer file sections each win while the user is working in them, never "last-mounted
372
+ silently grabs everything." With none focused it falls back to the top of the stack (the
373
+ last-mounted surface — the right default for a dialog stacked over a screen). A `usePasteFiles`
374
+ with no `region` is a pure stack participant, so a single existing consumer is byte-for-byte
375
+ unchanged. `FileDropTarget paste` passes its own region, so you get focus routing for free.
376
+
377
+ Both drop and paste are web behaviors with native no-op siblings — wrap/call them
378
+ unconditionally on a shared screen. The two templates are the worked examples of the
379
+ WHOLE-SURFACE shape: `examples/tpl_record.tsx` wraps its ENTIRE section stack in one
380
+ `<FileDropTarget … paste disabled={aiOpen}>` → a drop/paste anywhere on the record opens the
381
+ `intakeFiles` fork dialog; `examples/tpl_item_list.tsx` wraps its whole register page → a
382
+ drop/paste anywhere opens the "Enter data" intake PRE-SEEDED with the dropped files (the
383
+ register also keeps a section-scoped `FileDropTarget` on the record drawer's Files section, so
384
+ both scopes are shown). Each is one `intakeFiles`/`addFiles`/seed handler behind the wrapper
385
+ plus the Add/Enter CTA.
386
+
387
+ **Make the two invisible paths discoverable.** A `FileDropTarget`-wrapped section shows no
388
+ dropzone, so drag + paste are invisible unless you SAY so. Every files section carries a muted
389
+ affordance line under its heading — **`<SectionHeadingTitle description="Drag, paste, or click
390
+ to add files">`** (both templates do this). A `FileDropzone`'s own hint already names the paste
391
+ (its default `fileDropzone.hint` is "or click, or paste (⌘V)").
392
+
343
393
  ### Default: `FilesEditor`
344
394
 
345
395
  **`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
@@ -368,7 +418,8 @@ Reach for the lower-level pieces below only when you need custom chrome.
368
418
  ### The pieces — `FileDropzone`, `FileGrid`, `FileGalleryModal`, `FileRow`
369
419
 
370
420
  Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
371
- accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`**
421
+ accent; click falls back to a picker; a mounted zone is also the paste sink, so Ctrl/Cmd+V
422
+ works inside an open upload dialog); display what landed with **`<FileGrid files uploads>`**
372
423
  ABOVE the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add
373
424
  more" affordance — only the empty state leads with it).
374
425
 
package/docs/templates.md CHANGED
@@ -178,7 +178,12 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
178
178
  **disabled** checkbox (the same gating as any blocked line). A row's status is
179
179
  `Badge variant="dot"` — never the tonal `Badge` (that's the drawer/header twin; → [composition
180
180
  grammar §"Status indicators have a WEIGHT"](./composition.md)) — and the SAME dot vocabulary
181
- drives the status facet. A per-row action `Button` (here Print) is the row's primary action in
181
+ drives the status facet. A cell that carries supporting detail is a HIERARCHY, not two peers:
182
+ the identity on top (`size="sm"`, `weight="medium"` when it IS the row's name), its supporting
183
+ value beneath as `size="xs" color="muted"`, `gap: 2` — and a category / type / attribute goes
184
+ on THAT line, never a second chip beside the name (which reads as its peer, or louder when
185
+ colored, inverting what the row is scanned by; → [composition grammar §`Badge` is for STATUS
186
+ only](./composition.md)). A per-row action `Button` (here Print) is the row's primary action in
182
187
  the trailing column, `⋯` its overflow. A select-all band, footer totals + `Pagination`, and a
183
188
  `FloatingActionBar` carrying the bulk action while rows are ticked.
184
189
  - **A row press opens the PRODUCTION workspace `Drawer`**, which demonstrates the full
@@ -193,7 +198,9 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
193
198
  drawer header swaps to a back button + the pushed id and the ◀ ▶ sequencer hides; back
194
199
  pops with scroll preserved.
195
200
  - **Files** with its own Add CTA — `FileRows` CRUD via `pickFiles`, plus an EXPECTED
196
- document as a ghost `FileRow placeholder` with a Request action.
201
+ document as a ghost `FileRow placeholder` with a Request action. The section is wrapped in
202
+ a `<FileDropTarget … paste>`, so a drag anywhere onto it or a Ctrl/Cmd+V lands on the same
203
+ `addFiles` handler; a `SectionHeadingTitle description` names the three ways in.
197
204
  - **Payment** — the `Ledger` (charge/received groups with sums, peekable fee rows,
198
205
  `LedgerTotal`) + a Record-payment popover that appends a receipt.
199
206
  - **Activity** — the CRM touch-log shape: optional outcome pills (`ChipGroup`,
@@ -205,7 +212,11 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
205
212
  whose press opens the shared `FileGalleryModal`.
206
213
  - A closing `DangerZone`.
207
214
  - **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
208
- dialog: a `FileDropzone` hero (drop the customer's files) a short ANALYZE stream
215
+ dialog. The whole register page is ALSO a `<FileDropTarget paste>` (the whole-surface intake
216
+ standard): a file dropped or Ctrl/Cmd+V pasted ANYWHERE on the register opens this same dialog
217
+ PRE-SEEDED with the dropped files (list-first — the paid "Read with AI" stays a deliberate
218
+ button). The dialog is CONTROLLED (`open`/`onOpenChange` + `seedNames`) so both the button and
219
+ the top-level drop drive it. The dialog itself: a `FileDropzone` hero (drop the customer's files) → a short ANALYZE stream
209
220
  (`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
210
221
  analysis surfaced (described options, one custom-answer slot) → the IMPORT stream drafts the
211
222
  records → `ChangeRecord` cards to Keep/Drop (fee editable in place) + one outcome-named
@@ -280,6 +291,13 @@ billing, and quick-capture templates. Top → bottom:
280
291
  - **Documents** — the Agents "Document desk" pattern (this template is its worked example —
281
292
  see the Agents chapter below): the register `Table` (search · Add files) whose selection
282
293
  feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
294
+ The template is the worked example of the THREE-way, WHOLE-SURFACE file intake: the Add-files
295
+ CTA plus a `<FileDropTarget … paste disabled={aiOpen}>` wrapping the ENTIRE section stack (not
296
+ just Documents) — a drag or Ctrl/Cmd+V ANYWHERE on the record (`disabled` while the intake
297
+ dialog runs so a paste can't start a second one) — all landing in ONE `intakeFiles` fork
298
+ dialog. The Documents section therefore carries NO drop target of its own (the whole-record
299
+ one covers it), only its Add-files CTA and a muted `SectionHeadingTitle description` ("Drag,
300
+ paste, or click to add files") that keeps the otherwise-invisible paths discoverable.
283
301
  Every row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
284
302
  fills it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
285
303
  (mixed footprints misalign the identity column). The desk holds what ARRIVES; generation
@@ -57,6 +57,7 @@ import { FollowScroll } from "@lotics/ui/follow_scroll";
57
57
  import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
58
58
  import { CardSelectItem } from "@lotics/ui/card_select_item";
59
59
  import { FileDropzone } from "@lotics/ui/file_dropzone";
60
+ import { FileDropTarget } from "@lotics/ui/file_drop_target";
60
61
  import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
61
62
  import { useScreenSize } from "@lotics/ui/use_screen_size";
62
63
  import { MemberSelect } from "@lotics/ui/member_select";
@@ -461,6 +462,11 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
461
462
  const [declared, setDeclared] = useState<number | null>(isSeed ? 25_000_000 : null);
462
463
  const [tags, setTags] = useState<string[]>(isSeed ? (hs.trangThai === "hoantat" ? ["corporate"] : ["corporate", "courier"]) : []);
463
464
  const [files, setFiles] = useState<DisplayFile[]>(isSeed ? [sampleDoc("id-scan", "#3b82f6"), sampleDoc("application-form", "#10b981")] : []);
465
+ // THE standard files intake — one handler, three ways in: the section's Add
466
+ // CTA, a drag anywhere onto the Files section, and Ctrl/Cmd+V — the last two
467
+ // both ride the section's `<FileDropTarget paste>` below. Every record surface
468
+ // carries all three; the user should never have to hunt for a dropzone.
469
+ const addFiles = (picked: File[]) => setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
464
470
  // ── the case's money: CHARGES owed vs RECEIPTS received — outstanding is
465
471
  // the difference. Recording a payment appends a receipt; everything
466
472
  // (bar, group sums, the closing number) recomputes from the two lists.
@@ -688,17 +694,23 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
688
694
  <Divider />
689
695
  {/* FILES — the document CRUD: FileRows (press → gallery preview, ⋯ →
690
696
  remove) + the section's OWN CTA; an EXPECTED document renders as a
691
- ghost placeholder row with its own chase action. */}
697
+ ghost placeholder row with its own chase action.
698
+ The whole section is a FileDropTarget with `paste` — drag a file
699
+ anywhere onto it, or Ctrl/Cmd+V while focus is in it — the standard
700
+ files-intake behavior every record surface should carry. */}
701
+ <FileDropTarget onFiles={addFiles} paste>
692
702
  <Section>
693
703
  <SectionHeading>
694
- <SectionHeadingTitle>Files</SectionHeadingTitle>
704
+ {/* The standard files-section affordance line — drag / paste / click
705
+ are otherwise invisible on a FileDropTarget-wrapped section. */}
706
+ <SectionHeadingTitle description="Drag, paste, or click to add files">Files</SectionHeadingTitle>
695
707
  <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta>
696
708
  <Button
697
709
  title="Add file"
698
710
  color="secondary"
699
711
  onPress={() => {
700
712
  void pickFiles({ multiple: true }).then((picked) => {
701
- if (picked.length > 0) setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
713
+ if (picked.length > 0) addFiles(picked);
702
714
  });
703
715
  }}
704
716
  />
@@ -711,6 +723,7 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
711
723
  trailing={<Button title="Request" color="muted" onPress={() => {}} />}
712
724
  />
713
725
  </Section>
726
+ </FileDropTarget>
714
727
  <Divider />
715
728
  {/* the PAYMENT summary — a plain financial STATEMENT: the two sides
716
729
  of the ledger (charges owed · receipts received), every amount on
@@ -1101,14 +1114,22 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
1101
1114
  /** The manual gate stays the THREE-field create-then-refine form — past the
1102
1115
  * popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
1103
1116
  * own workspace (the drawer it opens into) is where everything else is refined. */
1104
- function EnterDataDialog({ onCreate, onCreateMany }: {
1117
+ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany }: {
1118
+ /** Controlled open — the intake is driven by BOTH the toolbar "Enter data"
1119
+ * button (opens empty) AND the register's whole-surface FileDropTarget (opens
1120
+ * PRE-SEEDED via `seedDocs`), so the trigger lives with the parent. */
1121
+ open: boolean;
1122
+ onOpenChange: (open: boolean) => void;
1123
+ /** Files to pre-seed the intake WELL when the dialog opens from a top-level
1124
+ * drop/paste — a dropped pile lands as thumbnail cards (list-first, NOT
1125
+ * auto-analyze), ready for the deliberate "Read with AI". Empty from the button. */
1126
+ seedDocs: DisplayFile[];
1105
1127
  onCreate: (khach: string, dienThoai: string, phi: number) => void;
1106
1128
  onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
1107
1129
  }) {
1108
1130
  const { small } = useScreenSize();
1109
- const [open, setOpen] = useState(false);
1110
1131
  const [phase, setPhase] = useState<IntakePhase>("intake");
1111
- const [fileNames, setFileNames] = useState<string[]>([]);
1132
+ const [docs, setDocs] = useState<DisplayFile[]>([]);
1112
1133
  const [variant, setVariant] = useState<ManualVariant>("export");
1113
1134
  const [grouping, setGrouping] = useState<"customer" | "order">("customer");
1114
1135
  // Q2's answer is load-bearing too: it decides how the unknown customer lands
@@ -1123,13 +1144,25 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1123
1144
  const [dienThoai, setDienThoai] = useState("");
1124
1145
  const [phi, setPhi] = useState<number | null>(null);
1125
1146
 
1126
- // The stream reveal one revealer serves both the analyze and import phases.
1147
+ // Opening PRE-SEEDS the intake well: a top-level drop/paste passes the dropped
1148
+ // files via `seedDocs`, the button passes []. Keyed on `open` alone — the seed
1149
+ // is settled before the parent flips open, and re-seeding on every render would
1150
+ // fight the user's own add/remove among the thumbnails.
1151
+ useEffect(() => {
1152
+ if (open) {
1153
+ setPhase("intake");
1154
+ setDocs(seedDocs);
1155
+ }
1156
+ }, [open]);
1157
+
1158
+ // The analyze stream names the files it reads; the reveal serves analyze + import.
1159
+ const docNames = docs.map((d) => d.filename);
1127
1160
  const [revealed, setRevealed] = useState(0);
1128
- const script = phase === "analyze" ? analyzeScript(fileNames) : phase === "running" ? importScript(grouping) : [];
1161
+ const script = phase === "analyze" ? analyzeScript(docNames) : phase === "running" ? importScript(grouping) : [];
1129
1162
  useEffect(() => {
1130
1163
  if (phase !== "analyze" && phase !== "running") return;
1131
1164
  setRevealed(0);
1132
- const total = phase === "analyze" ? analyzeScript(fileNames).length : importScript(grouping).length;
1165
+ const total = phase === "analyze" ? analyzeScript(docNames).length : importScript(grouping).length;
1133
1166
  let n = 0;
1134
1167
  const t = setInterval(() => {
1135
1168
  n += 1;
@@ -1145,6 +1178,11 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1145
1178
  // settled before their stream phase begins.
1146
1179
  }, [phase]);
1147
1180
 
1181
+ // ONE intake sink — every way IN adds to the pile: the whole-dialog
1182
+ // FileDropTarget (drag/paste anywhere on the open dialog), the big empty-state
1183
+ // dropzone, and the compact add-more dropzone all call this.
1184
+ const addDocs = (fs: File[]) => setDocs((prev) => [...prev, ...toDisplayFiles(fs)]);
1185
+
1148
1186
  const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1149
1187
  // Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
1150
1188
  // at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
@@ -1152,7 +1190,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1152
1190
 
1153
1191
  const reset = () => {
1154
1192
  setPhase("intake");
1155
- setFileNames([]);
1193
+ setDocs([]);
1156
1194
  setCardStatus({});
1157
1195
  setFees({});
1158
1196
  setCustomerPlan("New customer created and linked");
@@ -1160,7 +1198,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1160
1198
  setDienThoai("");
1161
1199
  setPhi(null);
1162
1200
  };
1163
- const close = () => { setOpen(false); reset(); };
1201
+ const close = () => { onOpenChange(false); reset(); };
1164
1202
 
1165
1203
  const applyProposals = () => {
1166
1204
  const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
@@ -1180,47 +1218,81 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1180
1218
  : "Import from files";
1181
1219
 
1182
1220
  return (
1183
- <>
1184
- <Button title="Enter data" color="primary" onPress={() => setOpen(true)} />
1185
- <ChangeReview>
1186
- <Dialog width={phase === "review" ? 560 : 480} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1221
+ <ChangeReview>
1222
+ {/* the file/stream phases (intake/analyze/clarify/running/review) run
1223
+ WIDE — room for the thumbnail hero + the AgentRun/ClarifyWizard/review
1224
+ streams; only the manual `form` stays a narrow single-column pane.
1225
+ 760 sits just under the kit Dialog's default 786 maxWidth cap. */}
1226
+ <Dialog width={phase === "form" ? 480 : 760} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1187
1227
  <DialogHeader>
1188
1228
  <DialogHeaderTitle>{title}</DialogHeaderTitle>
1189
1229
  </DialogHeader>
1190
1230
 
1191
1231
  {phase === "intake" ? (
1192
- <View style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
1193
- {/* the hero: AI reads the files no manual step */}
1194
- <FileDropzone
1195
- label="Drop the customer's files"
1196
- hint="Orders, invoices, spreadsheets AI reads them and drafts the records"
1197
- onFiles={(fs) => {
1198
- setFileNames(fs.length > 0 ? fs.map((f) => f.name) : ["orders-june.pdf", "manifest.xlsx"]);
1199
- setPhase("analyze");
1200
- }}
1201
- />
1202
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1203
- <View style={{ flex: 1 }}><Divider /></View>
1204
- <Text size="xs" color="muted">or enter manually</Text>
1205
- <View style={{ flex: 1 }}><Divider /></View>
1206
- </View>
1207
- <View style={{ gap: 8 }}>
1208
- <CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1209
- <Icon name="arrow-up" size={18} color={colors.zinc[700]} />
1210
- <View style={{ flex: 1, gap: 2 }}>
1211
- <Text size="sm" weight="semibold">Export case</Text>
1212
- <Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
1232
+ // The WHOLE open dialog is a FileDropTarget: drag OR paste anywhere on
1233
+ // it adds files, in BOTH modes (the ring/wash frames the dialog on drag
1234
+ // — "drop anywhere here"). Nested with the dropzones below, which each
1235
+ // light their own well; stopPropagation keeps one drop from double-adding.
1236
+ <FileDropTarget onFiles={addDocs} paste style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
1237
+ {/* ONE dialog, TWO MODES (not two Dialogs): FILES-PRESENT shows the
1238
+ pile + a compact add-more well + the deliberate Read; EMPTY shows
1239
+ the big dropzone hero + the manual chooser. Dropping in either ADDS
1240
+ files (via addDocs) — the paid AI read stays a DELIBERATE button,
1241
+ never a side effect of a drop. A pile means the user came to
1242
+ process files, so the manual cards fall away. */}
1243
+ {docs.length > 0 ? (
1244
+ <View style={{ gap: 16 }}>
1245
+ {/* the pile, as document cards — removable, the hero at top */}
1246
+ <FileThumbnailGrid
1247
+ files={docs}
1248
+ minItemWidth={120}
1249
+ onRemove={(id) => setDocs((prev) => prev.filter((d) => d.id !== id))}
1250
+ />
1251
+ {/* add-more is a real WELL (draggable + clickable), not a button —
1252
+ compact so the pile stays the hero */}
1253
+ <FileDropzone
1254
+ height={96}
1255
+ label="Drop more files"
1256
+ hint="or click to add"
1257
+ onFiles={addDocs}
1258
+ />
1259
+ <Button
1260
+ title={`Read ${docs.length} ${docs.length === 1 ? "file" : "files"} with AI`}
1261
+ color="primary"
1262
+ onPress={() => setPhase("analyze")}
1263
+ />
1264
+ </View>
1265
+ ) : (
1266
+ <>
1267
+ <FileDropzone
1268
+ label="Drop the customer's files"
1269
+ hint="Orders, invoices, spreadsheets — read them with AI"
1270
+ onFiles={addDocs}
1271
+ />
1272
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1273
+ <View style={{ flex: 1 }}><Divider /></View>
1274
+ <Text size="xs" color="muted">or enter manually</Text>
1275
+ <View style={{ flex: 1 }}><Divider /></View>
1213
1276
  </View>
1214
- </CardSelectItem>
1215
- <CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1216
- <Icon name="arrow-down" size={18} color={colors.zinc[700]} />
1217
- <View style={{ flex: 1, gap: 2 }}>
1218
- <Text size="sm" weight="semibold">Import case</Text>
1219
- <Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
1277
+ <View style={{ gap: 8 }}>
1278
+ <CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1279
+ <Icon name="arrow-up" size={18} color={colors.zinc[700]} />
1280
+ <View style={{ flex: 1, gap: 2 }}>
1281
+ <Text size="sm" weight="semibold">Export case</Text>
1282
+ <Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
1283
+ </View>
1284
+ </CardSelectItem>
1285
+ <CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1286
+ <Icon name="arrow-down" size={18} color={colors.zinc[700]} />
1287
+ <View style={{ flex: 1, gap: 2 }}>
1288
+ <Text size="sm" weight="semibold">Import case</Text>
1289
+ <Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
1290
+ </View>
1291
+ </CardSelectItem>
1220
1292
  </View>
1221
- </CardSelectItem>
1222
- </View>
1223
- </View>
1293
+ </>
1294
+ )}
1295
+ </FileDropTarget>
1224
1296
  ) : null}
1225
1297
 
1226
1298
  {phase === "analyze" || phase === "running" ? (
@@ -1336,8 +1408,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1336
1408
  </DialogFooter>
1337
1409
  ) : null}
1338
1410
  </Dialog>
1339
- </ChangeReview>
1340
- </>
1411
+ </ChangeReview>
1341
1412
  );
1342
1413
  }
1343
1414
 
@@ -1405,6 +1476,15 @@ export function TplItemList() {
1405
1476
  records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
1406
1477
  revealNew();
1407
1478
  };
1479
+ // The intake dialog is CONTROLLED here so BOTH the toolbar button and the
1480
+ // whole-register FileDropTarget drive it: the button opens it empty, a
1481
+ // top-level drop/paste opens it PRE-SEEDED with the dropped files (mapped to
1482
+ // DisplayFiles so the pile shows as thumbnail cards — the paid "Read with AI"
1483
+ // stays a deliberate button).
1484
+ const [intakeOpen, setIntakeOpen] = useState(false);
1485
+ const [intakeSeed, setIntakeSeed] = useState<DisplayFile[]>([]);
1486
+ const openIntake = () => { setIntakeSeed([]); setIntakeOpen(true); };
1487
+ const openIntakeSeeded = (dropped: File[]) => { setIntakeSeed(toDisplayFiles(dropped)); setIntakeOpen(true); };
1408
1488
  const [sort, setSort] = useState<SortState | null>(null);
1409
1489
  const [assignee, setAssignee] = useState<string[]>([]);
1410
1490
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
@@ -1475,6 +1555,17 @@ export function TplItemList() {
1475
1555
 
1476
1556
  return (
1477
1557
  <View style={{ flex: 1 }}>
1558
+ {/* WHOLE-SURFACE INTAKE — the standard this template DEMONSTRATES: the ENTIRE
1559
+ register page is one `FileDropTarget`, so a file dropped or a Ctrl/Cmd+V
1560
+ pasted ANYWHERE on the register opens the intake dialog PRE-SEEDED with
1561
+ those files (list-first — the paid "Read with AI" stays a button). No
1562
+ `disabled`: the intake's own inner `FileDropzone` and the record drawer's
1563
+ Files section are later-mounted paste sinks, so while either is open the
1564
+ paste routes THERE (top of stack); a nested drop is stopPropagation-safe.
1565
+ `flex:1` keeps the ScrollView filling the page (the bar + drawer overlay
1566
+ on top); the accent ring frames the page on drag. A real app opts in
1567
+ per-app — this page is the reference shape. */}
1568
+ <FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
1478
1569
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
1479
1570
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
1480
1571
  {/* header — title only; the New case action lives on the toolbar row */}
@@ -1546,7 +1637,9 @@ export function TplItemList() {
1546
1637
  onChange={(v) => { setFeeAmount(v); setPage(0); }}
1547
1638
  />
1548
1639
  </View>
1549
- <EnterDataDialog onCreate={createRecord} onCreateMany={createRecordsBulk} />
1640
+ {/* the intake TRIGGER — the register's whole-surface drop/paste opens
1641
+ the same dialog (see the FileDropTarget wrapper below) */}
1642
+ <Button title="Enter data" color="primary" onPress={openIntake} />
1550
1643
  </View>
1551
1644
 
1552
1645
  {/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
@@ -1603,6 +1696,11 @@ export function TplItemList() {
1603
1696
  </View>
1604
1697
  </View>
1605
1698
  </ScrollView>
1699
+ </FileDropTarget>
1700
+
1701
+ {/* the intake dialog — CONTROLLED, driven by the toolbar button AND the
1702
+ whole-register drop/paste above (opened seeded with the dropped files) */}
1703
+ <EnterDataDialog open={intakeOpen} onOpenChange={setIntakeOpen} seedDocs={intakeSeed} onCreate={createRecord} onCreateMany={createRecordsBulk} />
1606
1704
 
1607
1705
  {/* bulk action over the ticked cases — the shared floating bar */}
1608
1706
  <FloatingActionBar