@lotics/ui 26.4.1 → 27.7.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/MIGRATION.md CHANGED
@@ -4,6 +4,76 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 27.4.0 — a `Select` trigger announces as `combobox`, not `button`
8
+
9
+ No API change, but the DOM and the accessibility tree changed, so **a test or script that
10
+ finds a Select by its button role must be updated**:
11
+
12
+ ```ts
13
+ getByRole("button", { name: "Tags" }) // BEFORE — a <button> element
14
+ getByRole("combobox", { name: "Tags" }) // AFTER — a <div role="combobox">
15
+ ```
16
+
17
+ The trigger had to stop being a `<button>` element: the sanctioned chip box
18
+ (`renderSelected` → `<Chip onDismiss>`) puts a remove button inside the trigger, and
19
+ `<button>`'s content model forbids interactive descendants — so every multi Select with
20
+ chips rendered invalid HTML and threw a React error on mount. `combobox` is also the role
21
+ the APG pattern this control already follows calls for. Focus, Enter/Space, pointer open,
22
+ Escape, and chip removal are unchanged.
23
+
24
+ Note for `searchable`: `OptionList`'s filter input is a combobox too, so scope such a query
25
+ to the trigger (or use `.first()`) rather than assuming one match per Select.
26
+
27
+ ## 27.2.0 — a `LedgerGroup`'s sum moved below its rows
28
+
29
+ No API change; the rendering moved. A summed group used to print `LABEL … sum` as an eyebrow
30
+ ABOVE its rows and now closes them: the rows come first, then `Label sum` at `sm/medium`,
31
+ with no eyebrow (the label travels with the sum instead of being said twice).
32
+
33
+ **If you mix summed and unsummed groups in one `Ledger`, that now looks inconsistent** — an
34
+ unsummed group still captions from above, so the group names land in two different places.
35
+ This is the one case that wants an edit: give every group a `total`, and let a lone row stand
36
+ bare rather than grouping it (a one-row group's sum only restates the row). Omitting `total`
37
+ remains fully supported for a statement whose groups all name categories without summing them.
38
+
39
+ ## 27.0.0 — `TextButton` is REMOVED; an act carries a control surface
40
+
41
+ **`@lotics/ui/text_button` is gone, with no replacement component.** It was the wrong
42
+ abstraction, and 25.0.0 had already retreated from it inside `ReferenceField`.
43
+
44
+ It existed to solve one real problem: a fill-less `Button`'s optical edge is its INK, inset by its
45
+ padding, so it reads as indented against a column of labels. But paying for that with a third rung
46
+ cost more than it bought — **underline came to mean two things at once**, separable only by ink
47
+ (blue navigates, zinc acts), so the affordance stopped answering the only question a reader has:
48
+ does this take me away, or does it do something here. And it duplicated the `Button` colour ladder,
49
+ where `muted` already IS the quiet rung.
50
+
51
+ **The new law: an ACT carries a control surface; only NAVIGATION is underlined text.** Chrome has
52
+ two rungs — `Button` (40px, a surface) and `InlineButton` (28px, filled, on a field's own surface).
53
+ `Link`/`TextLink` stay underlined and go somewhere.
54
+
55
+ ```tsx
56
+ <TextButton onPress={selectAll}>Select all</TextButton> // BEFORE
57
+ <Button title="Select all" color="muted" onPress={selectAll} /> // AFTER
58
+
59
+ <TextButton color="danger" onPress={del}>Remove fee</TextButton> // BEFORE
60
+ <Button title="Remove fee" color="danger" onPress={del} /> // AFTER — rule 9's convention
61
+ ```
62
+
63
+ **Per call site, pick by WHERE the act sits, not by how quiet you want it to look:**
64
+
65
+ - In **chrome** — a `PopoverFooter`, a heading row, a select-all band — use `Button color="muted"`.
66
+ Nothing there establishes a text edge, so the padding inset costs nothing. (`FilterChip`'s Clear
67
+ and `OptionList`'s select-all/deselect-all moved this way inside the kit.)
68
+ - **Destructive** — `Button color="danger"`, bottom-left after the entity's fields, per composition
69
+ rule 9. That is the page-wide convention a text-weight verb was quietly opting out of.
70
+ - **An add** — `Button` below the list it extends, per rule 8.
71
+ - Still tempted to align a quiet verb to a column of TEXT? **Move the act, don't restyle it.** That
72
+ pull is what produced the rung in the first place.
73
+
74
+ `TextLink` is unchanged. Its `onPress`-free forms — an `href` link, or a passive underlined marker
75
+ on a value whose press opens a peek — were never this component's job and keep working.
76
+
7
77
  ## 26.0.0 — a section's ADD moves to its heading row; `EmptyState` loses its verb
8
78
 
9
79
  **`EmptyState.action` is REMOVED — no replacement on that component.** Two different things
package/docs/catalog.md CHANGED
@@ -467,8 +467,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
467
467
 
468
468
  - **`button`** — `Button`: the labelled action; `title` MANDATORY (it is the accessible
469
469
  name), `color` = emphasis/risk. A fill-less `Button` shows no box, so its optical
470
- edge is its INK, inset by its padding — don't align one to a column of TEXT; that
471
- is `TextButton`'s job.
470
+ edge is its INK, inset by its padding — don't align one to a column of TEXT. There is
471
+ no text-weight rung to reach for: move the act into chrome (a `PopoverFooter`, a
472
+ heading row) where no text edge exists to betray, or give it the filled tier.
472
473
  - **`icon_button`** — `IconButton`: the icon-only circular action (see
473
474
  [Actions](#actions)).
474
475
  - **`back_button`** — `BackButton`: the one go-back control — don't hand-roll it (an
@@ -485,18 +486,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
485
486
  with no `href` it stays NEUTRAL: plain underlined text to wrap in your own pressable,
486
487
  or a MARKER that a value leads somewhere (a record reference whose press opens a peek,
487
488
  not a trip). `color` overrides either way; inherits every `Text` prop. It does NOT
488
- act that's `TextButton`.
489
- - **`text_button`** `TextButton`: a low-chrome ACTION at text weight ("Select all",
490
- "Clear", "Add stop") — the third rung of chrome after `Button` (40px surface) and
491
- `InlineButton` (28px, filled, on a field's surface). **Underlined, in neutral ink**:
492
- underline marks interactive, blue marks navigation — so `Link` goes somewhere and
493
- this acts. Choose by what the press DOES. The underline is not optional (position is
494
- a learned convention, hover is absent on touch). `TextButtonColor` is
495
- `default | danger` only — `muted` would mute the thing carrying the act (use
496
- `Button color="muted"`). `onPress` REQUIRED; `disabled` greys to zinc-400, drops the
497
- press + wash, announces `aria-disabled`. Hover wash, focus ring and a 40px target
498
- (32px box + `hitSlop`) come from `PressableHighlight` — a pressable `Text` has
499
- none of them. ONE size, no icon (a verb needing a glyph is a `Button`).
489
+ act: an act carries a control surface (`Button`/`InlineButton`), and underlined text
490
+ is the NAVIGATION affordance only.
500
491
  - **`chip`** — `Chip`: the generic pill — pressable when `onPress` (announces as a button;
501
492
  pass `accessibilityLabel` when children aren't self-describing text) + an
502
493
  absolutely-positioned dismiss ✕ sibling when `onDismiss` (its name = `dismissTooltip` ??
@@ -588,7 +579,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
588
579
  also home of the shared `PickerOption` type.
589
580
  - **`select`** — `Select`: rich/custom-rendered, single/multi, select-all, chips via
590
581
  `renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
591
- a multi Select; opens `OptionList`.
582
+ a multi Select; opens `OptionList`. Its trigger is `role="combobox"` (a `<div>`), never
583
+ `role="button"`: the chip box legitimately puts a remove button INSIDE the trigger, and a
584
+ real `<button>` may not contain one. **Any trigger that renders caller-supplied content
585
+ must not take `accessibilityRole="button"`** — react-native-web picks the element from the
586
+ role, and `<button>`'s content model forbids interactive descendants, so the first caller
587
+ to pass a chip, link, or menu makes the markup invalid. Either take a non-button role, or
588
+ keep the verbs OUTSIDE the pressable as siblings (`Chip`'s absolute ✕, `InlineEditView`'s
589
+ shell-owned `actions`).
592
590
  - **`option_list`** — `OptionList`: the ONE shared searchable listbox body every selector
593
591
  opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
594
592
  typeahead; host it directly in a `Popover`/`Dialog` for a command palette.
@@ -838,18 +836,54 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
838
836
  `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
839
837
  never live in the header: compose them as `DetailTable`s in the sections below. Replaces
840
838
  hand-rolled record headers (mixed scales, several competing figures, color noise).
841
- - **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerRow` + `LedgerTotal` — the record-level
842
- money list: charge/receipt GROUPS with sums in their headers, every figure on ONE
843
- right-aligned tabular column, `peek` turns a row into a pressable door floating its
844
- particulars in an anchored popover (put links INSIDE the peek never a button in a
845
- button; `reference` is the trailing-link alternative for static rows), `LedgerTotal` = the
846
- divider-set emphasized close with `zeroLabel` for settled. The financial-statement grammar
847
- at record density; worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx)
848
- drawer.
839
+ - **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerBasis` + `LedgerRow` + `LedgerTotal` — the
840
+ record-level money list: every figure on ONE right-aligned tabular column, `peek` turns a row
841
+ into a pressable door floating its particulars in an anchored popover (put links INSIDE the
842
+ peek never a button in a button; `reference` is the trailing-link alternative for static
843
+ rows), `LedgerTotal` = the divider-set emphasized close with `zeroLabel` for settled. **`meta` is a
844
+ neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: one
845
+ row's meta being a fact and another's a complaint is what makes a column read inconsistent,
846
+ and a caption is the wrong weight for something wanting action. A problem goes on the row
847
+ that can FIX it, and in a statement the arithmetic has usually said it already.
848
+ **Pick the shape by what the statement IS**, because there are two and they read differently:
849
+ - **`charges → total`** — `LedgerGroup`s, each **closed by its own sum** ("Charges" vs
850
+ "Received"), giving three ascending rungs: row `sm/regular`, subtotal `sm/medium`, total
851
+ `md/semibold` over its rule. A summed group's label travels WITH its sum and takes no
852
+ eyebrow, so its rows are met before its name and must carry the side themselves, by opposite
853
+ SIGNS. Two same-signed groups are two statements, not two sides; give them two `Ledger`s.
854
+ A `total` must equal the rows it closes — derive the rows and the sum from ONE list, never
855
+ from two predicates that can drift apart — and it earns its place only by adding what the
856
+ rows don't already say, which a one-row group's sum doesn't. **Omitting `total` is a shape,
857
+ not a degradation**: the label becomes a caption ABOVE the rows, for a group that names a
858
+ category rather than summing a side. **Don't mix the two in one `Ledger`** — the label would
859
+ sit above some groups and below others, reading as an accident; if any group earns a sum,
860
+ give every group one and let a lone row stand bare instead of grouping it.
861
+ - **`basis → derived → total`** — duty off a customs value, commission off gross, interest
862
+ off principal. The base is **`LedgerBasis`** (label + value + optional `meta` for how it was
863
+ arrived at), which takes the third treatment on the money column and sets its `Divider`
864
+ BELOW it, since a premise separates itself from what it feeds. Do not render a base as a
865
+ `LedgerRow` — that claims it is a peer you could add to the rows under it — and do not wrap
866
+ it in a one-row group. With one side there is no group at all: the levies are bare
867
+ `LedgerRow`s between the basis and the total.
868
+ Worked example: [`tpl_record`](../examples/tpl_record.tsx) § Billing — a three-sided closing
869
+ statement (billed, less credited, less received) exercising every row state: a `peek` door
870
+ whose lookup link sits INSIDE the popover, a flat `reference` on the row with nothing to
871
+ expand, `meta` + `success` tone on money coming back, and a one-row `Adjustments` group that
872
+ takes no `total`. `Received` is derived from the charges carrying a payment METHOD rather than
873
+ a second flag to keep in step, and the refundable deposit stays OUT because a ledger earns
874
+ trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
875
+ Charts page instead — a delivery order has no base to compute from, and inventing one to
876
+ place a component is how invented needs start.
849
877
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
850
878
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
851
879
  like the editors above and below it), whose press opens a PEEK of that record's facts.
852
- `name` + optional `code` + `facts`, plus the verb callbacks below. The verbs live INSIDE the
880
+ `name` + optional `code` + `facts`, plus the verb callbacks below. The peek takes THE FIELD'S
881
+ WIDTH (`inheritTriggerWidth`, as `InlineSelect` does), floored at `MIN_CONTROL_WIDTH` — a
882
+ panel narrower than the control that opened it reads as belonging to something else. So the
883
+ field truncates its name to one line and the peek's header WRAPS the whole of it: the column
884
+ stays scannable, the full value is one press away. Between the floor and `labelWidth +
885
+ MIN_CONTROL_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width.
886
+ The verbs live INSIDE the
853
887
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
854
888
  dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
855
889
  four acts that are four acts. The row splits by WHAT EACH TOUCHES, and the spacer is that
@@ -948,7 +982,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
948
982
  - **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
949
983
  facet (options, a `Slider range`, a `Counter`).
950
984
  - **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
951
- view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band.
985
+ view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
986
+ AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
987
+ summarizes — including a record's child list (a consol's houses, an order's lines). **Never on
988
+ a record's identity band reporting that record's own state**: derived verdicts on one record
989
+ ("documents incomplete", "2 fields need checking") are the checklist a record must not carry
990
+ (§`pipeline`) and restate at a distance what the sections below state in place.
952
991
  - **`use_selection`** — `useSelection`: always-on multi-select state for a register/list —
953
992
  the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`;
954
993
  selectability gating stays with the caller. The checkbox-always-visible counterpart to
@@ -471,17 +471,19 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
471
471
  `danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis. A
472
472
  fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
473
473
  its INK, inset by its padding: **never align one to a column of TEXT** — it reads as indented
474
- against every label starting on the true edge, and reaching for the filled tier to fix that buys
475
- alignment with prominence, on what is usually the rarest act on the surface. A text-weight act is
476
- `TextButton`, which lands its ink on the column by construction. Chrome has three rungs: `Button` (a 40px control with a
477
- surface) `InlineButton` (28px, filled, on a field's own surface) → `TextButton` (no surface,
478
- sitting in a line of text). **UNDERLINE MARKS INTERACTIVE, BLUE MARKS NAVIGATION** — so
479
- `Link`/`TextLink` are blue + underline and GO somewhere, `TextButton` is zinc + underline and
480
- ACTS. Pick by what the press DOES. The underline is not optional on a surface-less control:
481
- POSITION is a learned convention rather than an affordance (invisible to a first-time reader) and
482
- hover is not one either (absent on touch, revealed only once you're already there). A tint is the
483
- other way to say it the Material/HIG answer but blue is spoken for here and a second accent
484
- for "actionable" would collide with one-accent-per-purpose. Never a `muted` text action: it mutes
474
+ against every label starting on the true edge. The answer is to move the act, not to invent a
475
+ rung for it: a quiet verb belongs in CHROME (a `PopoverFooter`, a heading row, a select-all
476
+ band) where nothing establishes a text edge to betray, and a destructive one belongs where rule 9
477
+ already puts it. Chrome has TWO rungs: `Button` (a 40px control with a surface) →
478
+ `InlineButton` (28px, filled, on a field's own surface).
479
+
480
+ **AN ACT CARRIES A CONTROL; ONLY NAVIGATION IS UNDERLINED TEXT.** `Link`/`TextLink` are the
481
+ underlined pair and they GO somewhere; everything that acts is a `Button` or an `InlineButton`.
482
+ A surface-less ACT was tried as a third rung and removed: it made underline mean two things at
483
+ once, separable only by ink, so the affordance stopped answering the one question a reader has
484
+ does this take me away, or does it do something here. It also competed with the `Button` colour
485
+ ladder for the same job, since `muted` already IS the quiet rung. Pick by what the press DOES,
486
+ and let the surface say which kind it is. Never a `muted` text action: it mutes
485
487
  the one thing carrying the act. No
486
488
  "success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
487
489
  (colored `Text`), not the buttons.
@@ -711,10 +713,10 @@ selection washes).
711
713
  (rose vs orange) stays on `Badge`s.
712
714
 
713
715
  Links use `Link`/`TextLink` (blue-600), never `solid("blue")` — and blue-600 means NAVIGATION
714
- specifically, not "interactive" generally: the underline is what marks a surface-less control
715
- interactive (see the button ladder above), so a `TextButton` ACTS and stays zinc. `TextLink` takes
716
- the blue only when it has an `href`; as a passive marker on a value it stays neutral, because a
717
- press that opens a PEEK is not a trip.
716
+ specifically, not "interactive" generally. Underlined text is the NAVIGATION affordance and nothing
717
+ else (see the button ladder above): an act carries a control surface. `TextLink` takes the blue only
718
+ when it has an `href`; as a passive marker on a value it stays neutral, because a press that opens a
719
+ PEEK is not a trip.
718
720
 
719
721
  ## `Badge` is for STATUS only — everything else is text
720
722
 
package/docs/templates.md CHANGED
@@ -42,8 +42,7 @@ the package index is [../AGENTS.md](../AGENTS.md).
42
42
  | A guided sequence of physical tasks (scan, confirm, next) | `tpl_pick` |
43
43
  | Splitting one source amount across many targets | `tpl_allocate` |
44
44
  | A record's create/edit surface — also the settings shape | `tpl_record` |
45
- | A checklist ON a record (the canonical task-list grammar) | `tpl_item_list` (its Tasks section) |
46
- | A team task board (inline-managed grouped table) | `tpl_task_board` |
45
+ | A surface whose SUBJECT is tasks (the canonical task-list grammar) | `tpl_task_board` |
47
46
  | One record HANDED between desks — stages that each own their controls | `tpl_record` (its Progress section) |
48
47
  | Financial statements | `tpl_statements` |
49
48
  | A scoped lookup report with export | `tpl_report` |
@@ -336,25 +335,36 @@ billing, and quick-capture templates. Top → bottom:
336
335
  `DrawerFooter`. "Add fee" rides the section heading's right edge — the one place it does not
337
336
  move as rows arrive — and is create-then-refine: a blank fee opens straight in the drawer. The
338
337
  empty state carries no button of its own, because that one is already on screen.
339
- - **Billing — a REGISTER, the same shape as Fees.** Both sections are a list of money items
340
- where each item has detail and a per-item act, so both are a compact `Table` whose row opens
341
- its FULL detail in a right-docked `Drawer` (◀ step the invoices) the drill-down law.
342
- It was three inline bands stacked, each with its own charge table, callout and action row,
343
- plus two more bands after them: five subsections and four primaries in one section, which is
344
- what made the area read as spread. A row now carries title, lines, total, a DERIVED state
345
- `Badge`, the issued ref; the charge editors (ghost list prices with one-tap "Standard …"
346
- fill, "How paid…" selects) live in the drawer with the lines they bill, and the section ends
347
- with ONE summary (`SummaryLine`: to collect, deposit, issued) and ONE action row.
338
+ - **Billing — INLINE bands, closed by a STATEMENT.** Each invoice is a `Subsection` costing ONE
339
+ line of chrome (its name, its total, its issued ref) over its charge rows in a `DetailTable`;
340
+ the charge editors carry ghost list prices with a one-tap "Standard …" fill and a "How paid…"
341
+ select. Three shapes were tried. Inline bands with a heading rung, a band callout and an
342
+ action row each made the CHROME three times the content; a `Table` + right-docked `Drawer`
343
+ then hid four charge lines behind three expansions, which trades a real fault for a worse one
344
+ you can no longer see what you are paying for. So the lines all stay and the chrome
345
+ shrank, and a per-invoice problem rides the CHARGE ROW that has it (`warning` on the line
346
+ missing its method), which is smaller than a callout and more precise: it names the offender
347
+ instead of counting offenders.
348
+ The section then CLOSES with a `Ledger` — the money grammar's worked example, and the answer
349
+ the bands cannot give: three invoice totals down the page are three facts the reader has to
350
+ add up, and "is anything still owed" appeared nowhere. THREE sides — `Invoiced`, a one-row
351
+ `Adjustments` credit, and `Received` with its rows negative and `success`-toned — so the total
352
+ states arithmetic rather than a difference, over a `LedgerTotal` carrying `zeroLabel="Paid in
353
+ full"`. Every row state is on screen: a `peek` door on the multi-line invoice (its lookup link
354
+ INSIDE the popover, since a button cannot hold a button), a flat `reference` on the single-line
355
+ one, `meta` qualifying how money came back. `Received` is DERIVED from the charges that carry a
356
+ payment method — not a second flag to keep in step — and the refundable deposit stays OUT,
357
+ because a ledger earns trust by summing exactly what its label claims. The fixture is
358
+ deliberately uneven (an unpriced charge, one issued invoice, a one-row group) because a tidy
359
+ one verifies nothing.
348
360
  THE ACTION-GATING LAW is still the worked example: a not-ready Issue is DISABLED and the
349
- reason is a co-located `Callout` at the gate's SCOPE, once — an invoice's own inconsistency
350
- (a charged line with no method) in its drawer, a broken record premise (no customer / invalid
351
- tax ID) at the section top; self-evident empties stay silent, and NEVER prose beside a CTA.
352
- Issuing gates on record premises only — never silently on stage. Issue sits in the DRAWER's
353
- footer, which is the one place a CTA right-aligns (an overlay footer; on the page it would
354
- ride the control column). It is also the worked example of **an irreversible action taking an
355
- ID, not a captured object** the confirm re-resolves the invoice from its key, so the quoted
356
- total is the one that will be billed even when the press was held for a charge cell's write
357
- (data_entry.md § Inline edit).
361
+ reason is a co-located `Callout` at the gate's SCOPE, once — an invoice's own inconsistency (a
362
+ charged line with no method) on that line, a broken record premise (no customer / invalid tax
363
+ ID) at the section top; self-evident empties stay silent, and NEVER prose beside a CTA.
364
+ Issuing gates on record premises only — never silently on stage. It is also the worked example
365
+ of **an irreversible action taking an ID, not a captured object** the confirm re-resolves the
366
+ invoice from its key, so the quoted total is the one that will be billed even when the press
367
+ was held for a charge cell's write (data_entry.md § Inline edit).
358
368
  - **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
359
369
  law worked on the record surface: the top is intake, the bottom produces on demand).
360
370
  Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
@@ -159,7 +159,7 @@ const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label
159
159
  // Columns defined ONCE — Table renders the header from these and each
160
160
  // TableCell takes its width/align by position; the ⋯ is the trailing gutter.
161
161
  // `priority` = the MOBILE contract: when the container can't fit every column
162
- // the highest numbers drop first (Received → Tasks → Status), the identity +
162
+ // the highest numbers drop first (Received → Status), the identity +
163
163
  // Customer + Fee survive longest, and at the register floor rows STACK
164
164
  // (label-over-value) so nothing is lost — the drawer carries full detail
165
165
  // either way.
@@ -715,8 +715,8 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
715
715
  <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
716
716
  <Text size="xs" color="muted">
717
717
  {variant === "export"
718
- ? "The export case starts in Processing — details, tasks, and payment live on the record itself."
719
- : "The import case starts in Processing — details, tasks, and payment live on the record itself."}
718
+ ? "The export case starts in Processing — details and payment live on the record itself."
719
+ : "The import case starts in Processing — details and payment live on the record itself."}
720
720
  </Text>
721
721
  <FormField label="Customer">
722
722
  {khach === "" ? (
@@ -779,9 +779,8 @@ export function TplItemList() {
779
779
  const [search, setSearch] = useState("");
780
780
  const [page, setPage] = useState(0);
781
781
  const [openMa, setOpenMa] = useState<string | null>(null);
782
- // ONE task state per record the register column, its peek popover, and the
783
- // drawer all read and write the same list. Paid state lifts the same way:
784
- // settling the ledger in the drawer flips the row's Unpaid/Print/menu live.
782
+ // Paid state is LIFTED, not owned by the drawer: settling the ledger inside the
783
+ // drawer flips the row's Unpaid/Print/menu live, because both read one source.
785
784
  const [paidMap, setPaidMap] = useState<Record<string, boolean>>({});
786
785
  const daThuOf = (r: HoSo) => paidMap[r.ma] ?? r.daThu;
787
786
  // The register rows are state: the create dialog PREPENDS a real row and