@lotics/ui 41.0.1 → 41.3.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/docs/templates.md CHANGED
@@ -241,11 +241,10 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
241
241
  - **Linked records** still push an editable screen for that record inside the drawer via the
242
242
  hosted `ScreenRouter` — while `canGoBack` the drawer header swaps to a back button + the
243
243
  pushed id and the ◀ ▶ sequencer hides; back pops with scroll preserved.
244
- - Two things this template no longer demonstrates, so look elsewhere: the drawer does NOT
244
+ - One thing this template no longer demonstrates, so look elsewhere: the drawer does NOT
245
245
  write back to its row (settling a ledger used to flip the register's paid state live — the
246
- drawer's money is the record's now), and the CRM call-log shape (outcome pills over a
247
- multiline note, an outcome-typed history `Timeline`) went with the Activity section, which
248
- the record surface has no counterpart for.
246
+ drawer's money is the record's now). The communications log lives on the record surface as
247
+ [`tpl_record`](../examples/tpl_record.tsx) § Activity.
249
248
  - **The "Enter data" INTAKE FORK** (the New CTA) — AI FIRST, the form as fallback, one phased
250
249
  dialog. The whole register page is ALSO a `<FileDropTarget … paste>` (the whole-surface intake
251
250
  standard): a file dropped or Ctrl/Cmd+V pasted ANYWHERE on the register opens this same dialog
@@ -457,11 +456,123 @@ billing, and quick-capture templates. Top → bottom:
457
456
  a label-left capture form (auto-stamped date/receipt-no, record-prefilled fields, the
458
457
  right input each) whose CTA and produced `FileRow` ride an empty-label `DetailRow` — the
459
458
  form-action alignment law (data_entry.md), worked.
460
- - **No Activity or Handoff section.** Progress owns both: it says where the record SITS and
461
- carries the act that moves it, so a second section repeating the handoff and a `Timeline` of
462
- every change beside it were two more places to look for one answer. What that costs is
463
- explicit: the sibling record a handoff creates has no home on this surface, and there is no
464
- audit trail. Both belong on the Progress stage that produced them if an app needs them back.
459
+ - **Activity what was SAID with the other party**, newest first, directly under Progress: the
460
+ two answer a reader's first two questions in order, where the record stands and what passed
461
+ between us to get it there. It is NOT an audit trail and NOT the handoff see the next bullet
462
+ for the line, which is what keeps this section from becoming the change log Progress refuses.
463
+
464
+ **The row's LABEL is the GIST** — what came of the exchange, in the reader's own words. The
465
+ medium and the direction are metadata on the muted supporting line beneath it. Building it the
466
+ other way round is the natural mistake and it makes the feed unreadable: every row renders as a
467
+ pair of enum values ("Outbound Meeting"), the substance sits behind a chevron, and a column of
468
+ near-identical labels defeats the scan the feed exists for. Measured on a real one, that shape
469
+ spent 504px of chrome on 70px of content.
470
+
471
+ **ONE anatomy, a body that VARIES.** An entry can be a call, a recorded demo, an email, a
472
+ message or a note typed between meetings — and those differ in what they CARRY, not in what
473
+ they are: all of them answer what-came-of-it, which way, over what, and when. So the four
474
+ invariants are the row, and the body is a SET OF BLOCKS rather than a shape per kind. A shape
475
+ per kind writes the same block five times and lets the copies drift, and the sixth kind — the
476
+ one nobody has thought of — needs a sixth. **Every block is optional and any combination is
477
+ legal**, because what an entry carries is independent of what it is: a call may arrive as a
478
+ recording alone, gain a transcript minutes later and a summary after that.
479
+
480
+ The blocks, in the order they render (`ActivityBody` in `tpl_record` is the worked one):
481
+
482
+ | Block | What it is | How it renders |
483
+ |---|---|---|
484
+ | Participants | ONLY what cannot be derived — a third party who joined, an email's From/To. The record is already the counterparty and the row already says who logged it, so naming the obvious attendees is padding | muted line |
485
+ | Subject | the one thing an email has that nothing else does | `sm` medium |
486
+ | Media | the exchange itself, recorded | `MediaPlayer` — **give it a box**, it fills its parent and collapses to nothing without one |
487
+ | Transcript | VERBATIM and long; the source a summary was made from | a muted underlined `TextLink` in your own `Pressable`, revealing PLAIN text — see the disclosure exception in composition.md §"Commit & feedback surfaces" |
488
+ | Prose | what a PERSON wrote — a note, a counterparty's email | `Markdown variant="embedded"` on the page's own ground |
489
+ | AI prose | what a MODEL wrote | ONE short label INSIDE the `Inset` ("AI summary") — floating above it, a 12px muted fragment over a tinted box reads as an orphan. Name the evidence only where it is NOT on screen: over a player the reader can see, "from the recording" is four words restating a video. Let the label replace the block's own `## Heading` rather than sit above it |
490
+ | Files | what came with it | documents → `FileRow`s (a name identifies them); images → `FileThumbnailGrid` (content does) |
491
+ | Source | where it happened, if openable | `Link` |
492
+ | Author | who filed it | `MemberChip` for a person; `WaveAvatar` + name for an automation |
493
+
494
+ **The verbs — Edit and Delete — live at the BOTTOM of the open body, not in the row's trailing
495
+ slot.** Three defects came out of putting a control on the label row (a button nested in the
496
+ row's own button, a fixed-height box to hold it on the first line, and a gap close enough to
497
+ the chevron to read as one cluster) and every one of them exists only because something
498
+ interactive shared the row. In the body there is nothing to share with, the verbs sit beside
499
+ the content they act on, and the expansion the reader already chose IS the look-before-you-
500
+ delete step — which makes "two clicks" the feature rather than the cost. Provenance sits left
501
+ on that footer, the verbs right; they are two groups, so space pushes them apart rather than a
502
+ uniform gap making them peers.
503
+
504
+ Delete belongs there DESPITE the feed being a record — because of what a multi-writer feed
505
+ actually produces. An extraction filing against the wrong contact and an automation
506
+ double-filing a recording are not history, and a feed with no way to remove them accumulates
507
+ garbage it also asks you to trust. Name what leaves with the entry in the confirmation: a
508
+ count somewhere else is the part nobody expects.
509
+
510
+ Only the GIST and the note are editable — everything else is derived, verbatim, an artifact or
511
+ a model's output. The editor is a MODE behind an **Edit** verb rather than a resident field,
512
+ because the gist is already the row's label and a resident one renders it twice; an EMPTY gist
513
+ shows its field regardless, because that is the write-up path for an entry an automation filed.
514
+ The rule and its two corollaries are in data_entry.md §"When the editor is a MODE instead of a
515
+ resident field".
516
+
517
+ **A block earns its place by carrying what nothing else on the row does.** The failure is not
518
+ a missing block, it is three blocks saying one thing: a participants line, a rep's note and a
519
+ machine summary all reporting that the spreadsheet owner was absent. When the summary is good
520
+ the rep usually adds nothing, and an entry with no note is the normal case rather than an
521
+ incomplete one. Cut on sight: a label above a block that already labels itself, a fact the
522
+ record or the row already states, and any block whose content the reader can see rendered
523
+ directly above it.
524
+
525
+ **Two of those rows are the whole point of the section on a modern record.** A feed fills from
526
+ more than one direction — a person, a chat agent, an extraction off an upload, an automation
527
+ firing on a file — so the surface has to say which values nobody wrote and which a machine did.
528
+ An entry with no prose renders `placeholder` (see `Timeline`), never a phrase assembled from
529
+ the enums; and a machine's text is separated from a person's by SHAPE — an attribution line
530
+ plus a recessed ground — never by a weight nudge. The reasoning and the failure modes are in
531
+ ai_patterns.md §After the run.
532
+
533
+ **The transcript folds, and it reveals in place.** It is the source a summary was made from,
534
+ read rarely and in full when at all — and the reason anyone opens it is to check a claim the
535
+ summary made, so a dialog is wrong: it takes the claim off screen at the moment they want to
536
+ compare. Reveal it whole rather than into a scroll box; a scroller inside a scrolling drawer
537
+ traps the wheel, and a reader who pressed it asked for the length.
538
+
539
+ **The toggle is underlined text, and that is a deliberate exception.** Three controls were
540
+ tried. `Button color="muted"` measures transparent, borderless and undecorated at rest — the
541
+ wash arrives only on hover, so the affordance is hover-only (banned in §"Commit & feedback surfaces") and it
542
+ reads as a stray label between two content blocks. `Accordion` fixes the affordance and
543
+ overcorrects: it is a LIST-ROW primitive, a full-width header with its own chevron, nested
544
+ inside a row that already has one. What is left is a muted underlined `TextLink` in your own
545
+ `Pressable` — visible at rest, on the column's own left edge, and carrying no more weight than
546
+ the words. The ink keeps underline honest: blue leaves the surface, muted stays on it.
547
+
548
+ **Reveal it PLAIN, with no tinted well.** The toggle directly above already says what the text
549
+ is; a panel would put two identical recessed boxes on one row meaning two different things — a
550
+ verbatim record and a machine's reading of it. Keep the `Inset` for the machine's.
551
+
552
+ **The capture sits AT the feed**, as a `Composer` — compact until typed into. Logging an entry
553
+ is this section's input, not a distinct AREA of the record, and a section of its own costs a
554
+ rail entry, which is a claim that there is somewhere else to go.
555
+
556
+ **The tail FOLDS, it does not page.** A record's feed is read newest-first and the rest is
557
+ reference; an unbounded one makes every section below it unreachable by scroll. Show the recent
558
+ few with a toggle naming how many are behind it — a register pages, a record's feed folds.
559
+
560
+ The toggle is a `TextDisclosure`, the same control as the transcript's, because it is the same
561
+ act: it reveals more of what is already here. A `Button color="muted"` is the reach that feels
562
+ right and is not — measured, it is transparent, borderless and undecorated at rest, so the
563
+ affordance only arrives on hover and it reads as a stray line under the feed. It sits on the
564
+ SECTION's left edge, not the rows': the rows are inset by the disc rail, and at the fold's line
565
+ the rail has ended, so nothing visible occupies that indent. Same for any other fold on the
566
+ surface — a document set's "show the rest" is this control too.
567
+ - **No Handoff section.** Progress owns it: it says where the record SITS and carries the act
568
+ that moves it, so a second section repeating the handoff was one more place to look for one
569
+ answer. What that costs is explicit: the sibling record a handoff creates has no home on this
570
+ surface. It belongs on the Progress stage that produced it if an app needs it back.
571
+
572
+ **Activity is not that trail, and the line is the SUBJECT.** Activity carries what a person
573
+ said or sent to a counterparty; the handoff trail carries where the record moved and which
574
+ field changed. Put desk transitions in Activity and it becomes the audit log this template
575
+ declined to build — a feed of everything, scanned for nothing.
465
576
 
466
577
  Recall is the undo: confirmed, it withdraws the sibling, clears the custody stamp that
467
578
  handoff wrote, and returns the record to the desk BEFORE it — one step, never a jump to the
@@ -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}