@lotics/ui 22.2.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +27 -25
  4. package/docs/catalog.md +119 -98
  5. package/docs/composition.md +134 -42
  6. package/docs/data_entry.md +67 -31
  7. package/docs/templates.md +86 -68
  8. package/examples/tpl_allocate.tsx +5 -5
  9. package/examples/tpl_attendance.tsx +2 -2
  10. package/examples/tpl_calendar.tsx +6 -6
  11. package/examples/tpl_dashboard.tsx +9 -9
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_item_list.tsx +64 -37
  14. package/examples/tpl_lookup.tsx +5 -5
  15. package/examples/tpl_pick.tsx +6 -6
  16. package/examples/tpl_pivot.tsx +3 -3
  17. package/examples/tpl_record.tsx +919 -611
  18. package/examples/tpl_report.tsx +2 -2
  19. package/examples/tpl_rollup.tsx +5 -5
  20. package/examples/tpl_shifts.tsx +5 -5
  21. package/examples/tpl_statements.tsx +7 -7
  22. package/examples/tpl_stock.tsx +2 -2
  23. package/examples/tpl_task_board.tsx +42 -26
  24. package/examples/tpl_tower.tsx +6 -6
  25. package/package.json +3 -2
  26. package/src/agent_run.tsx +40 -25
  27. package/src/breakdown.tsx +1 -1
  28. package/src/calendar/calendar_view.tsx +1 -1
  29. package/src/change_review.tsx +9 -8
  30. package/src/chip_group.tsx +12 -2
  31. package/src/choice_list.tsx +2 -2
  32. package/src/confidence.tsx +2 -2
  33. package/src/data_grid.tsx +1 -1
  34. package/src/detail_row.tsx +50 -58
  35. package/src/file_dropzone.tsx +1 -1
  36. package/src/file_gallery_modal.tsx +3 -3
  37. package/src/file_rows.tsx +1 -1
  38. package/src/finding.tsx +4 -4
  39. package/src/form_field.tsx +1 -1
  40. package/src/format_date.ts +2 -2
  41. package/src/heatmap.tsx +1 -1
  42. package/src/inline_button.tsx +84 -0
  43. package/src/inline_date_picker.tsx +17 -10
  44. package/src/inline_edit.tsx +298 -59
  45. package/src/inline_member_select.tsx +8 -3
  46. package/src/inline_number_input.tsx +11 -4
  47. package/src/inline_select.tsx +26 -13
  48. package/src/inline_text_input.tsx +12 -4
  49. package/src/inline_time_picker.tsx +10 -4
  50. package/src/ledger.tsx +2 -2
  51. package/src/locale.tsx +7 -3
  52. package/src/matrix.tsx +1 -1
  53. package/src/number_input.tsx +18 -7
  54. package/src/pipeline.tsx +1 -1
  55. package/src/popover.tsx +1 -1
  56. package/src/press_door.tsx +1 -1
  57. package/src/pressable_highlight.tsx +1 -1
  58. package/src/progress_bar.tsx +3 -3
  59. package/src/record_summary.tsx +2 -2
  60. package/src/result_header.tsx +2 -2
  61. package/src/sequence.tsx +170 -0
  62. package/src/share_or_download.ts +2 -2
  63. package/src/step_progress.tsx +7 -5
  64. package/src/stepper.tsx +1 -1
  65. package/src/task.tsx +6 -6
  66. package/src/text_input_field.tsx +19 -1
  67. package/src/text_utils.ts +1 -1
  68. package/src/linked_record_box.tsx +0 -157
@@ -44,8 +44,9 @@ restyle a heading level per-page.
44
44
  numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
45
45
  - **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
46
46
  (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
47
- at the column edge; `SectionHeadingMeta` and actions ride the heading row's right edge (the
48
- title's `flex: 1` pushes them). The page column is a **`SectionStack`** it owns the
47
+ at the column edge; `SectionHeadingMeta` and any VIEW control ride the heading row's right edge
48
+ (the title's `flex: 1` pushes them) but a verb that CHANGES the content does not: see the
49
+ action-placement law below. The page column is a **`SectionStack`** — it owns the
49
50
  between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
50
51
  null/false children are skipped so a conditional section never leaves a stray hairline). The
51
52
  `Divider` NEVER goes directly under a heading — that orphans the title from its own content.
@@ -317,7 +318,7 @@ card): `<PressableRow onPress>` + `<PressDoor onPress accessibilityLabel="Open
317
318
  content on `zIndex: 1` (the door hit-tests above in-flow siblings, so the content must lift back
318
319
  over it). `PressableHighlight` is the wrong base for such a row — it IS a button and wraps its
319
320
  children, so any nested control lands inside it; it stays for surfaces whose content is
320
- non-interactive by construction. `TableRow` and `LinkedRecordBox` already compose `PressDoor`.
321
+ non-interactive by construction. `TableRow` already composes `PressDoor`.
321
322
 
322
323
  Actions stay **visible** — a hover-only action is invisible to keyboard and touch users. The ONLY
323
324
  exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
@@ -424,11 +425,10 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
424
425
  `danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis. No
425
426
  "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
426
427
  (colored `Text`), not the buttons.
427
- - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". Section-heading creates are
428
- `secondary` (one primary per scope, reserved for the scope's main verb); a create promotes to
429
- `primary` only where creating IS the surface's job the register page's New, an empty state's
430
- action. (The icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps
431
- its glyph.)
428
+ - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
429
+ sits BELOW the list it extends, never in the section heading see the verb-placement law for
430
+ why, and for the one-verb-one-weight rule that keeps it primary in the `EmptyState` too. (The
431
+ icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps its glyph.)
432
432
 
433
433
  ## The action-gating law — disabled + the reason as a co-located Callout
434
434
 
@@ -456,57 +456,148 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
456
456
  - **The field-annotation vocabulary** — THREE tones, same names + meanings on `FormField` and
457
457
  `DetailRow`, always EXPLICIT (never a hidden ⓘ on rows): **`description`** (info, muted) = a
458
458
  content/domain fact or persistent guidance, under the VALUE on a horizontal row — the label
459
- column has no room for prose; stacked mode wears the form order label · description · control;
459
+ column has no room for prose; stacked mode wears the form order label, description, control;
460
460
  **`warning`** (amber) = a consequence the user should weigh before acting, announced on
461
461
  appearance; **`error`** (danger + alert semantics) = an invalid state under the value (the
462
462
  `Inline*` editors already render their own transient save errors); cross-field / record-level
463
463
  STATE = a co-located `Callout`. Every tone is USEFUL AND TERSE — a short clause stating the
464
464
  fact or consequence, never widget mechanics, never a second sentence; `warning` marks a
465
465
  consequence, not decoration. Rows share ONE
466
- alignment law: the row top-aligns and label · control · trailing each center within the
466
+ alignment law: the row top-aligns and label, control, trailing each center within the
467
467
  first control line — a tall value block never drags the label, and a LABEL too long for its
468
468
  column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
469
469
  the value. A FLAT value row
470
470
  (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
471
471
  control band's slack so the perceived gap under the text equals the gap under a chip —
472
472
  the band's invisible bottom half must never read as a hole above the description.
473
- - **A LINKED record never renders as field rows**another record's data must not read as
474
- fields on THIS one. Use **`LinkedRecordBox`** (`tpl_record`'s Customer/sibling are the worked
475
- examples): a BORDERED box (glyph anchor · identity · facts stacked vertically) whose verbs
476
- ride its `actions` slot in a hairline-fenced footer INSIDE the box destructive LEFT in
477
- danger, go-to RIGHT. One verb set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
478
- "Đổi"/"Gỡ"): with a clearable link, changing = remove the empty-state picker re-prompts, so
479
- the destructive verb covers both required anchors and optional adds.
480
- **`onOpen` decides whether the box is a DOOR, and the kit makes the two exclusive.** Pass it
481
- (with `doorLabel`) when the linked record HAS a detail surface: the whole box presses open, its
482
- facts are TEXT, and editing that record happens on ITS page, never here. OMIT it when the record
483
- has NO page of its own the box renders static (no tab stop, no pointer, nothing announced as a
484
- button), the explicit `actions` verbs are its only affordances, and a `facts` value may then be a
485
- NODE holding the inline editor, since there is no destination a press could steal. Never wire a
486
- door "so the box presses": it sends the reader somewhere they did not ask to go and duplicates
487
- whatever change verb the footer already shows.
473
+ - **A verb about a VALUE goes ON the value an `InlineButton` in the field's `actions`.** Copy
474
+ this reference, call this number, open this record: the act names the value, so it travels
475
+ with what it acts on, and a reader never has to pair a control sitting at the page's right
476
+ edge with the field it belongs to. `DetailRow` has NO trailing slot, because a reserved
477
+ column is paid by every row for the benefit of one it was declared on the TABLE, so a
478
+ single `Copy` made every value cell stop short of an edge nothing occupied. EVERY `Inline*`
479
+ editor takes `actions`, popover-backed ones included a field exposes its outer box
480
+ separately from the control inside it, so a select's list and a date's calendar still open at
481
+ the field's own width. Anything that is not a verb a status `Badge`, a unit — composes into
482
+ the value cell instead.
483
+ `actions` renders in BOTH view and edit mode, and it must be passed UNCONDITIONALLY
484
+ `disabled` the verb when it has nothing to act on. A slot that appears once the value is
485
+ non-empty changes the field's width as the user types, which is the one thing an inline
486
+ editor promises never to do.
487
+ - **What is about the WHOLE record is a PANEL, not a section.** A section takes a position in a
488
+ top-to-bottom reading order, which is a claim that it belongs at that point in the read. A
489
+ record's DISCUSSION does not: a note is written about anything on the page and is wanted while
490
+ you work anywhere on it, so parked at one position it is unreachable from every other — exactly
491
+ when it matters. Dock it in the page's right gutter (the one the outline rail's balance already
492
+ reserved), pinned, scrolling inside itself so a long thread never lengthens the record. Give it
493
+ no rail entry — it is not a place you navigate to. Narrow, it collapses onto the same bar the
494
+ sections use, on the RIGHT: sections are where you ARE, the discussion is what is being SAID,
495
+ and the two poles keep the sides they hold on a wide screen.
496
+ - **A VARIABLE-LENGTH, ORDERED fact is a `Sequence`, not a row of fields.** Before reaching for
497
+ any layout lever, check the section is not carrying a MODELLING error: `Origin` /
498
+ `Transfer point` / `Destination` looks like three fields and is actually one route — it cannot
499
+ hold a second transfer, and three identical rows say nothing about which comes first. The same
500
+ goes for an approval chain, a set of legs, a list of signatories. Rendered as a `Sequence` it
501
+ gains the one thing the grid could never give it — visible ORDER — and stops competing with the
502
+ fields around it, because it is no longer pretending to be one.
503
+ Nothing about spacing or disclosure fixes a section holding the wrong SHAPE, and a section that
504
+ looks "unattended" is usually this rather than a density problem.
505
+ - **A VERB GOES WHERE ITS EFFECT LANDS.** One law places every action on a record surface, and
506
+ it is the same one that decides `InlineButton` vs a trailing column:
507
+
508
+ | the act changes… | it lives… |
509
+ |---|---|
510
+ | a VALUE (copy it, open what it points at) | ON the value — `InlineButton` in the editor's `actions` |
511
+ | a ROW (remove it, move it) | ON the row — an `ActionMenu`, or the row's own controls |
512
+ | the LIST (one more of them) | BELOW the list, left-aligned, `primary` |
513
+ | the RECORD (issue, print, hand off) | its own action row at the section's END |
514
+ | the record's EXISTENCE | the `DangerZone`, last on the page |
515
+
516
+ The heading row is therefore identity + meta + view controls, and holds no content verb. An
517
+ **Add in the heading sits above the thing it extends**, at a different altitude from the rows
518
+ it affects, competing with the title for the one line that names the section — while the row it
519
+ creates appears at the BOTTOM, nowhere near the button that made it. Put it under the last row,
520
+ on the list's own left edge, and the button is where its result will be.
521
+ **An add is `primary`, in the `EmptyState` too.** It is the act its section offers, so it
522
+ carries that section's weight; a lone `secondary` button reads as though the real action were
523
+ somewhere else. The rule that matters is that ONE VERB KEEPS ONE WEIGHT — the same add must
524
+ not be primary on an empty list and secondary on a full one.
525
+ A record surface therefore carries several primaries, one per section, and that is correct:
526
+ "one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
527
+ its own single act. The cost is real and worth knowing — the more sections offer an act, the
528
+ less any one of them stands out — so the answer to a loud page is FEWER SECTIONS WITH ACTS,
529
+ never a quieter button.
530
+ - **A DENSE section is compacted by a SUMMARY above it, by ROW HEIGHT, and by grouping fields
531
+ that are ONE ENTITY — never by a two-column layout.** The evidence is unusually clear here, so
532
+ do not re-derive it: Baymard finds an overall two-column form layout "generally problematic and
533
+ never recommended" (empty columns pull attention in several directions; fields get skipped), and
534
+ NN/g measures single-column completion materially faster. What IS sanctioned is 2–3 inputs on
535
+ ONE line **when they logically belong to a single coherent entity** — their examples are
536
+ day/month/year, first/middle/last name, city/state/ZIP. A route (origin, via, destination) is
537
+ that; two unrelated short fields sharing a line to save height is not.
538
+ **But that exception belongs to the LABEL-ABOVE form grammar, not to a `DetailTable`.** Every
539
+ example of it (day/month/year, city/state/ZIP) sits in a form where each input already owns a
540
+ label on its own line. A `DetailRow` puts the label in a LEFT COLUMN, so a grouped row can only
541
+ name its parts by adding a sub-label line no other row has — which drops its controls off the
542
+ baseline every other row shares — and naming them with placeholders loses the names the moment
543
+ the row is filled. In a label-left grid, ONE FIELD PER ROW is not a failure to compact; it is
544
+ the only shape that keeps the column a reader scans. Both attempts are recorded here so nobody
545
+ re-runs them.
546
+ The other caveat: that research studies COMPLETION forms, where the failure mode is a skipped
547
+ required field, while a record page is read-mostly.
548
+ A SUMMARY belongs to the RECORD, not to a section. The highlights-panel precedent (a strip of
549
+ key facts above the detail) is a PAGE-level device, where what it summarises is a scroll or a
550
+ tab away; repeated per section it sits inches above the very rows it repeats, so it reads as
551
+ duplication and adds a third block of chrome — section title, description, strip — before any
552
+ data. Put the few facts someone would ask for by phone in the record's own header, once.
553
+ A read-only view behind an Edit button is the last resort: it compacts by DROPPING empty
554
+ fields — and an empty row is information, the difference between "this record has no vehicle"
555
+ and "this section has no vehicle field" — and it costs a MODE, against the law that the surface
556
+ IS the editor.
557
+ - **A REFERENCE to another record is a FIELD, not a section and not a card.** The kit already
558
+ draws every other reference as a token — a person is a `MemberChip`, a file a `FileBadge`, a
559
+ select option an `OptionBadge` — and a record is no different. It renders as a VALUE wearing
560
+ the **inline editor's own resting surface** (the 40px band, the white bordered chip, the radius), so
561
+ a pointer at another record sits in the value column exactly like the editors above and below
562
+ it. Put BOTH the name and its code INSIDE it — one entity reads as one object, and a muted
563
+ code beside a link reads as an orphan.
564
+ **THREE acts, placed by how often they happen and what they cost to undo**: pressing the
565
+ surface PEEKS the facts (free, so it gets the biggest target); an **`InlineButton`** Open sits
566
+ INSIDE on the right (the common act, so it stays in sight and stays labelled — a press target
567
+ you discover by hovering is not an affordance); and DETACH lives in the peek's footer
568
+ (destructive and rare, and it is the answer to the question the peek asks — "is this the right
569
+ one?" — so the check and the correction are one gesture). A detach link repeated down a column
570
+ of references is noise in the scan path for an act most readers never perform.
571
+ The interior verb REQUIRES the anatomy a container-with-controls always requires: a role-less,
572
+ non-focusable surface (a button must never contain a button), an empty `PressDoor` sibling
573
+ carrying the tab stop / name / focus ring, and the verb lifted above it on `zIndex`. Track
574
+ hover on the container itself, or react-native-web releases it to the innermost pressable and
575
+ the border flickers out as the pointer crosses the verb.
576
+ **Never promote it to a Section.** A section claims a distinct AREA of the record and buys an
577
+ outline-rail entry; one pointer at one other row never earns that. What tempts the promotion is
578
+ the machinery around it — and each piece is just part of the field: the search is its EMPTY
579
+ state, the create dialog is a modal, the validity problem is the row's `warning`.
580
+ **Never render the other record's fields as rows on this one** either — the reference carries
581
+ IDENTITY; any fact THIS record actually uses (a tax id that gates invoicing) is this record's
582
+ own field or the row's annotation, and everything else belongs on the record you open. When
583
+ the referenced record has NO surface of its own, it has no identity to reference: its values
584
+ simply ARE this record's fields — put them in a `Subsection`, with no box around them.
488
585
  **The empty / attach state is a find-or-create `Combobox`, never a bare picker** (`tpl_record`'s
489
586
  Customer is the worked example): rows are `renderOptionContent`-rich, NOT a wall of bare names —
490
- the premium shape is a **glyph tile (28·radius 7·zinc-100) · the name over a muted metadata line**
491
- that carries the type + identifying fact as TEXT (`type · code · city`), joined by "·" and free to
492
- wrap the row taller. A type/attribute is **NOT a status, so it is text, never a `Badge`** (see
493
- Badge discipline below). The row should read like the `LinkedRecordBox` it becomes reuse the same
494
- kind-glyph. (The kit suppresses `renderOptionContent` on the `allowCustom` "Create …" row so it keeps its plain + look.)
587
+ the premium shape is a **glyph tile (28, radius 7, zinc-100) beside the name, over a muted metadata
588
+ line** that carries the type + identifying fact as TEXT (`type, code, city`) — COMMA-joined, never
589
+ a middot (see Microcopy), and free to wrap the row taller. A type/attribute is **NOT a status, so it is text, never a `Badge`** (see
590
+ Badge discipline below). (The kit suppresses `renderOptionContent` on the `allowCustom` "Create …" row so it keeps its plain + look.)
495
591
  Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}`. In
496
592
  `onValueChange`, an existing pick attaches; the custom row opens a create **`Dialog` prefilled with
497
593
  the typed query** (mint the record + link it) — so a party absent from the registry is created
498
594
  WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
499
595
  keyboard highlight stays on the first MATCH, so Enter on a partial never makes a duplicate. A
500
- linked record seeded by a workflow (not user-attached) skips the picker — show the box or an
501
- `EmptyState`, no create affordance. The component OWNS the door variant's press ANATOMY (the
502
- error-prone part hand-rolling got wrong): the box is a `PressableRow` (role-less, non-focusable surface hover wash spans
503
- the nested verbs) with the keyboard DOOR an EMPTY absolutely-positioned `role="button"`
504
- sibling carrying the tab stop, the accessible name and the focus ring — beneath the content,
505
- the interior verbs lifted above it via `zIndex: 1`. NEVER `role="button"` on a container with
506
- interactive descendants: a button must not contain a button (invalid HTML, a hydration error,
507
- and AT double-announcement). That fenced, lifted footer is the ONLY place a control may sit
508
- inside a door box — a control in the facts column would compete with the press for the same
509
- click, which is why a node fact typechecks only on the doorless variant.
596
+ linked record seeded by a workflow (not user-attached) skips the picker — show the chip or an
597
+ `EmptyState`, no create affordance.
598
+ **One verb set across every reference of a group** (a party wall's five links all read "Gỡ", not
599
+ a mix of "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker
600
+ re-prompts, so the destructive verb covers both required anchors and optional adds.
510
601
 
511
602
  ## Color discipline — solid / tint / ramp + ONE accent
512
603
 
@@ -652,7 +743,7 @@ that praises it is marketing.
652
743
 
653
744
  ### Error messages — what happened → what it means → what to do
654
745
 
655
- The consensus anatomy (GOV.UK · NN/g · Microsoft): state what happened, what it means for the
746
+ The consensus anatomy (GOV.UK, NN/g, Microsoft): state what happened, what it means for the
656
747
  user's data, and the one next step. Field errors go INLINE (`FormField error=`); a page-level
657
748
  failure is a `Callout tone="error"` (title = what happened, text = what to do, an action when
658
749
  one exists).
@@ -663,7 +754,8 @@ one exists).
663
754
  - Empty required field → instruct ("Enter the hours you work a week"); constraint violation →
664
755
  describe the constraint ("First name must be 35 characters or less").
665
756
  - Apology is reserved for genuine system failure — never for validation.
666
- - BAD: `Error: invalid input (0x643).` · GOOD: `Không tải được chứng từ lên — tệp vẫn còn trên
757
+ - BAD: `Error: invalid input (0x643).`
758
+ GOOD: `Không tải được chứng từ lên — tệp vẫn còn trên
667
759
  máy bạn. Thử lại; nếu vẫn lỗi, kiểm tra kết nối mạng.`
668
760
 
669
761
  ### Empty states — confirm, orient, one action
@@ -13,14 +13,14 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
13
13
  | You're capturing… | Reach for | Why |
14
14
  |---|---|---|
15
15
  | an EXISTING record's fields | [**Inline edit**](#inline-edit--the-preferred-way-to-edit-an-existing-record) (`Inline*`) | edit in place, no form mode |
16
- | a value in a DENSE ROW / register `DataGrid` cell (a task's due · assignee · status) | the SAME `Inline*` editor in **`variant="cell"`** | transparent at rest + a background-tint wash on hover (not the form's chip + border), value-first (the date drops its calendar glyph) one editor per value type, `variant` picks form vs grid; there is no separate `*Cell` family |
16
+ | a value in a DENSE ROW / register `DataGrid` cell (a task's due, assignee, status) | the SAME `Inline*` editor in **`variant="bare"`** | one editor per value type; `bare` drops the resting frame (the grid already promises every cell edits) and fades the same border in on hover there is no separate `*Cell` family |
17
17
  | a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
18
18
  | a RELATED record (pick or make) | [**find-or-create**](#find-or-create--the-combobox-family-is-the-control) (`Combobox allowCustom`) | one control covers both |
19
19
  | REPEATING rows you build & revise | [**line items**](#line-items--create--preview--edit-a-composition-not-a-primitive) (create→preview→edit) | add / edit / remove, live totals |
20
20
  | CHARGES that bill onto documents | [**billing**](#billing--the-invoice-document-is-the-unit) (`tpl_record` Billing section) | the invoice document is the unit |
21
21
  | a record's FEE/charge SUMMARY | [**`Ledger`**](#fee-summary--ledger) (worked example: `tpl_item_list` drawer) | grouped money lines, one emphasized total — no bars/charts |
22
22
  | a multi-value TAG field | [**`Select multi`**](#tag--multi-value-field--select-multi) (`renderSelected` → `Chip`) | chips composed, not a separate control |
23
- | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker` a stacked column of full-width choices, right when options carry descriptions) | required single-select, one tap, every option visible |
23
+ | ONE choice from a small visible set | [**`ChipGroup` pills**](#choosing-a-choice-control--by-option-count-not-by-taste)more options or long labels take `InlineSelect`; `RadioPicker` only where the choice IS the task | one tap, every option visible, and the row stays ONE band |
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 |
@@ -154,13 +154,12 @@ null). `InlineSelect multi` needs no `onClear` — an empty set is already a val
154
154
 
155
155
  ### The row stack — `DetailTable` + `DetailRow`
156
156
 
157
- A STACK of rows lives in a `DetailTable` (label · value · trailing laid out like a TABLE:
158
- `labelWidth` / `trailingWidth` / `minHeight` set ONCE on the parent, plus the 8px row gap the
159
- zinc-50 chips need) holding `DetailRow`s set `trailingWidth` when ANY row carries a trailing
160
- action/badge, so EVERY row reserves the column and one row's `Copy` button never makes its editor
161
- narrower than its neighbours'. Rows share ONE alignment law: the row top-aligns and label ·
162
- control · trailing each center within the first control line annotations (the three tones
163
- under the value: `description` muted · `warning` amber · `error` danger, each indented to the
157
+ A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
158
+ `minHeight` set ONCE on the parent, plus the `SPACE.md` row gap bordered fields need — a border
159
+ is a harder edge than the old tint, so rows 8px apart fused) holding `DetailRow`s. There are TWO columns and no third: the value FILLS whatever the label leaves, so
160
+ nothing one row does can narrow its neighbours' editors. Rows share ONE alignment law: the row
161
+ top-aligns and label, control each center within the first control line annotations (the three tones
162
+ under the value: `description` muted, `warning` amber, `error` danger, each indented to the
164
163
  chip's 8px text inset) grow the row downward without dragging the label. The full annotation
165
164
  vocabulary lives in composition.md §Field annotations. A long LABEL wraps inside its column
166
165
  instead of clipping — never shorten a field name to fit the label width, and never reach for a
@@ -168,23 +167,60 @@ tooltip to recover the tail; the wrapped label's first line stays level with the
168
167
 
169
168
  ### The editability affordance
170
169
 
171
- A `variant="form"` editor (the default) AT REST sits on a zinc-50 chip THE editability affordance:
172
- users see what's editable without hovering, and it hovers via a BORDER (an input's edge). Keep it
173
- wherever editable and static values MIX (a `DetailTable` / record field). `variant="cell"` opts out
174
- of the chip transparent at rest + a background-tint WASH on hover for DENSE, uniformly-editable
175
- surfaces (a register/`DataGrid` column, a task row where EVERY cell edits: the chip repeated
176
- everywhere is noise, and the wash matches the row/cell it sits in). A `disabled` editor rests FLAT
177
- automatically the chip is the editability promise, and an inert field must not make it.
170
+ An editor AT REST wears THE pill surface — WHITE with a 1px border, the same one `Chip`,
171
+ `ChipGroup` and a secondary `Button` wear. That is THE editability affordance: a read-only
172
+ `InlineStatic` stays flat and borderless, so users see what is editable without hovering, and a
173
+ bordered box beside bare text is a plainer signal than a tint. Hover marks the field you are about to act
174
+ on with a CHANGE you can see, which is why it differs by variant: a `framed` field deepens its
175
+ border AND tints (border alone would be zinc-200 zinc-400, a shade shift on a 1px line), while a
176
+ `bare` field has the border ARRIVE out of nothing, which is loud enough by itself and a tint
177
+ there would make a hovered cell lighter than the row washing under it. Open adds the 2px ring. A `disabled` editor rests FLAT and borderless
178
+ automatically — the surface is the editability promise, and an inert field must not make it.
179
+
180
+ **`variant` decides how much of that frame shows AT REST — an axis of WEIGHT, not of use.**
181
+ `"framed"` (default) keeps the surface visible: required wherever editable and static values MIX,
182
+ because the frame is the only thing saying which values you can change. `"bare"` shows nothing at
183
+ rest and fades the SAME border in on hover — for a surface where EVERY value is editable (a
184
+ register/`DataGrid` column, a task row), where a per-field frame states what the whole surface
185
+ already promises and, repeated down a column, draws the grid a second time. Both hover and open
186
+ identically, so this changes what a field looks like RESTING, never what it does. A hand-rolled
187
+ pressable cell should match `bare`: nothing at rest, the border on hover — never a background wash.
178
188
 
179
189
  A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
180
190
  **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
181
191
  border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
182
192
  glance and the static value never reads as a disabled input.
183
193
 
184
- To hang a right-side action/badge off a row, use `DetailRow`'s `trailing` slot (NOT for units —
185
- "kg"/"$" belong IN the value via `InlineNumberInput format`) — see the "General" section of
186
- `tpl_record`, which also reads top→bottom as a full record surface. Not every field is a
187
- same-height swapa tag field, a status, or an attachment grid edit in place too (below).
194
+ ### Choosing a CHOICE control by option count, not by taste
195
+
196
+ A field whose value is one-of-N has three answers, and the wrong one is what makes a record
197
+ surface look cluttered not the number of rows. A row that is four rows TALL shouts over every
198
+ neighbour, and variation in row WEIGHT reads as noise long before row COUNT does.
199
+
200
+ | The choice | Reach for | Why |
201
+ |---|---|---|
202
+ | 2–3 SHORT labels, switched often | **`ChipGroup`** | every option visible, ONE press to switch; the pills size to their words and wrap, so the row stays one band |
203
+ | More options, or long labels, or the value is set once and read after | **`InlineSelect`** | scales to any count; costs two presses (open, pick) and hides the alternatives until you do — put each option's gloss in `renderOptionContent`, where it is needed while CHOOSING rather than on every later read |
204
+ | The choice IS the task — a wizard step, a short focused form | **`RadioPicker`** | full-width choices with their descriptions permanently visible. In a grid of many fields this is ~4 rows of height for one value; never use it as a row in a record's field table |
205
+
206
+ **`SegmentedControl` is NOT a field control.** It belongs to the view-control vocabulary (a mode
207
+ or parameter of the SAME view — see the composition grammar), and it fails as a value editor for
208
+ two concrete reasons: it is a bounded TRACK, so in a value column that fills the row it either
209
+ stretches absurdly wide for two options or needs a hand-picked width; and its track+thumb is a
210
+ different visual species from the chip surface every other editor on the page wears. A `ChipGroup`
211
+ is the same `chipSurfaceStyle` as the controls around it, sizes to its content, and grows by
212
+ wrapping instead of squeezing.
213
+
214
+ A VERB about the value rides the FIELD — an **`InlineButton`** in an editor's `actions` (`Copy`
215
+ on a reference, `Call` on a phone): the act names the value, so it travels with what it acts on
216
+ instead of being paired back to a control at the page's right edge. Pass it UNCONDITIONALLY and
217
+ `disabled` it when there is nothing to act on — a verb that appears once the value is non-empty
218
+ resizes the field as the user types, which is the one thing an inline editor promises never to
219
+ do. Anything that is not a verb — a status `Badge`, a unit ("kg"/"$", which belong IN the value
220
+ via `InlineNumberInput format`) — composes into a row inside the VALUE cell, so it costs only the
221
+ row that wants it. See the "General" section of `tpl_record`, which also reads top→bottom as a
222
+ full record surface. Not every field is a same-height swap — a tag field, a status, or an
223
+ attachment grid edit in place too (below).
188
224
 
189
225
  ## Fieldset form — fields lay out on a RESPONSIVE two-column grid
190
226
 
@@ -280,7 +316,7 @@ changing desks — never an inbox, a notification, or a copied task. Two types:
280
316
  handoff is MANAGED AS TASKS — each desk's checklist on the record. The **`Task`** compound owns
281
317
  the list geometry (the control column, the indent, and `density` — 44px targets by default) —
282
318
  compose it, never hand-roll the row. A **`TaskItem`** carries a `TaskStatus` (a `CheckCircle` — a
283
- read-only ring when it has no `onChange`), a `TaskTitle` (a `variant="cell"` `InlineTextInput`,
319
+ read-only ring when it has no `onChange`), a `TaskTitle` (an `InlineTextInput`,
284
320
  or the title TEXT as a string child — `<TaskTitle struck={done}>{label}</TaskTitle>` is the
285
321
  read-only form, and the compound owns its inset and band, so never hand-roll a `Text` title) and
286
322
  a `TaskActions` ⋯ menu — never a decorative progress strip. The fields the desk SETS (the
@@ -303,7 +339,7 @@ about one FIELD is not a caption either: it is that sub-row's own `description`
303
339
 
304
340
  **THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** A field
305
341
  belonging to ONE task — its due date, its assignee, a portal login, a reference number — hangs
306
- under it as a **`TaskSubRow`** (`label` · the control · `description` / `warning` / `error`),
342
+ under it as a **`TaskSubRow`** (`label`, the control, `description` / `warning` / `error`),
307
343
  indented ONE step, with the NAME beside its VALUE so the eye pairs them. Belonging is expressed by
308
344
  that indentation, which is the same step a nested `TaskList` takes — one device, at every depth.
309
345
 
@@ -315,7 +351,7 @@ own words is not a column: it lined up the labels of a task whose names happened
315
351
  staggered every other one, and a vocabulary with longer field names than English staggered nearly
316
352
  all of them. Raise it once, on the list, for a vocabulary that genuinely needs more. The value
317
353
  takes the slack from a readable minimum, dropping onto its own line under the label on a surface
318
- too narrow to seat both. Use the form-variant `Inline*` editor (the default) `variant="cell"`
354
+ too narrow to seat both. Use the `Inline*` editor —
319
355
  belongs to a grid.
320
356
 
321
357
  **A TASK'S FIELD ANNOTATES EXACTLY LIKE A RECORD'S FIELD.** `TaskSubRow` carries the same
@@ -390,8 +426,8 @@ open tasks carry over. The handoff CTA opens a DIALOG for the receiving desk (as
390
426
  the record left this register.
391
427
 
392
428
  Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
393
- whose popover holds the same `Task` list — every `TaskItem`'s ring `TaskStatus` · struck
394
- `InlineTextInput` `TaskTitle` (flex) · a quick-reassign `InlineMemberSelect` in a `TaskSubRow`
429
+ whose popover holds the same `Task` list — every `TaskItem`'s ring `TaskStatus`, struck
430
+ `InlineTextInput` `TaskTitle` (flex), a quick-reassign `InlineMemberSelect` in a `TaskSubRow`
395
431
  under it — no expandable rows (tags/files depth is
396
432
  the Task list template's lesson, not the peek's); the popover body is `PopoverContent`'s own
397
433
  ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
@@ -431,7 +467,7 @@ create step, never a toggle on the record.
431
467
  When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
432
468
  split the screen into "enter fees here, issue there" — that smears one job across two places.
433
469
  Make **each invoice a FLAT hairline-set band that holds its own editable charge lines** (amount
434
- input + payment method), its **live total**, its **status badge** (nothing-to-bill · draft ·
470
+ input + payment method), its **live total**, its **status badge** (nothing-to-bill, draft,
435
471
  issued + ref), and its **issue action** in its closing row — no card chrome; data-capture
436
472
  templates are flat (see [the composition grammar](./composition.md)). A charge never lives apart
437
473
  from the document it bills on. Issuing is gated **inline, never a dead end**: when a prerequisite
@@ -446,7 +482,7 @@ receipt — never folded into the total due. Composition over `Card` + `NumberIn
446
482
  ## Fee summary — `Ledger`
447
483
 
448
484
  For a record's fee/charge SUMMARY (read-heavy, one payment action), reach for the `Ledger`
449
- compound: **`LedgerGroup`** (label + group sum) → **`LedgerRow`** (label · `meta` · ONE right
485
+ compound: **`LedgerGroup`** (label + group sum) → **`LedgerRow`** (label, `meta`, ONE right
450
486
  tabular money column; `peek` makes the row a door to its PARTICULARS in an anchored popover —
451
487
  references live INSIDE the peek, since a row is never a button holding another button (`reference`
452
488
  is ignored while `peek` is set); `reference` = a trailing link on a non-peek row — an issued
@@ -597,8 +633,8 @@ more" affordance — only the empty state leads with it).
597
633
 
598
634
  `FileGrid` is the upload-aware grid: `files` are the saved/completed `DisplayFile`s, `uploads` is
599
635
  the LIVE add-queue (`FileUpload[]`) — it interleaves both and renders each in-flight tile itself —
600
- a LABELED status overlay (uploading spinner · "Retrying" · "Paused" · "Upload failed" + a retry
601
- button · "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
636
+ a LABELED status overlay (uploading spinner, "Retrying", "Paused", "Upload failed" + a retry
637
+ button, "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
602
638
  `FileThumbnail`. Those words resolve **prop → `LoticsLocale.fileUpload` → nothing** — set the
603
639
  pack once at the root and a stalled or dead upload speaks the app's language everywhere,
604
640
  including inside `FilesEditor`, which has no per-instance way to reach them. `labels.upload` /
@@ -606,7 +642,7 @@ including inside `FilesEditor`, which has no per-instance way to reach them. `la
606
642
 
607
643
  Make it CRUDable by wiring its callbacks: `onFilePress` → set a `number|null` index that drives
608
644
  `<FileGalleryModal files activeIndex onIndexChange>` — a FULL-SCREEN viewer with a toolbar
609
- (filename · counter · actions — download, optional `onOpenExternal`/`onRemove` · close-✕),
645
+ (filename, counter, actions — download, optional `onOpenExternal`/`onRemove`, close-✕),
610
646
  prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a phone (`useScreenSize().small`) the
611
647
  actions collapse into a `⋯` `ActionMenu` so the close-✕ never overflows off-screen (its popover
612
648
  portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks one, which is why an inline
@@ -631,7 +667,7 @@ gallery at the first hidden file). This is the responsive overflow pattern (avat
631
667
  file-manager); prefer it over a hardcoded `maxVisible`, which can't adapt to width.
632
668
 
633
669
  Under the hood it composes `FileThumbnail` (the completed tile — right surface per MIME: image
634
- thumbnail · a doc tile with the `FileBadge` centered + a single-line filename · media card; also
670
+ thumbnail, a doc tile with the `FileBadge` centered + a single-line filename, media card; also
635
671
  takes `isTemplate` to overlay a TMPL marker) and `UploadingThumbnail` (the in-flight tile); reach
636
672
  for either directly only when hand-rolling a NON-grid layout. **When the filename must be
637
673
  readable, use `FileRow`** — a horizontal LINE (`FileBadge` or a `placeholder` + the FULL name + a