@lotics/ui 26.4.1 → 27.6.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,14 +836,44 @@ 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.
@@ -948,7 +976,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
948
976
  - **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
949
977
  facet (options, a `Slider range`, a `Counter`).
950
978
  - **`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.
979
+ view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
980
+ AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
981
+ summarizes — including a record's child list (a consol's houses, an order's lines). **Never on
982
+ a record's identity band reporting that record's own state**: derived verdicts on one record
983
+ ("documents incomplete", "2 fields need checking") are the checklist a record must not carry
984
+ (§`pipeline`) and restate at a distance what the sections below state in place.
952
985
  - **`use_selection`** — `useSelection`: always-on multi-select state for a register/list —
953
986
  the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`;
954
987
  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
@@ -35,6 +35,7 @@ import { FileRows } from "@lotics/ui/file_rows";
35
35
  import { FileGrid } from "@lotics/ui/file_grid";
36
36
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
37
  import { InlineStatic } from "@lotics/ui/inline_static";
38
+ import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
38
39
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
39
40
  import { InlineNumberInput } from "@lotics/ui/inline_number_input";
40
41
  import { InlineSelect } from "@lotics/ui/inline_select";
@@ -59,7 +60,6 @@ import { CheckboxInput } from "@lotics/ui/checkbox_input";
59
60
  import { ChipGroup, type ChipOption } from "@lotics/ui/chip_group";
60
61
  import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
61
62
  import { ReferenceField } from "@lotics/ui/reference_field";
62
- import { TextButton } from "@lotics/ui/text_button";
63
63
  import { InlineButton } from "@lotics/ui/inline_button";
64
64
  import { useSelection } from "@lotics/ui/use_selection";
65
65
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
@@ -278,9 +278,12 @@ interface Invoice {
278
278
 
279
279
  const BILLING_INITIAL: Invoice[] = [
280
280
  {
281
+ // Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
282
+ // so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
283
+ // Storage is the mirror case (one charge, issued) and takes the flat link instead.
281
284
  key: "delivery",
282
285
  title: "Delivery",
283
- ref: "",
286
+ ref: "INV-0029",
284
287
  charges: [
285
288
  { key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
286
289
  { key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
@@ -293,13 +296,19 @@ const BILLING_INITIAL: Invoice[] = [
293
296
  charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
294
297
  },
295
298
  {
299
+ // Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
300
+ // band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
301
+ // has happened yet only ever exercises the first half of a flow.
296
302
  key: "storage",
297
303
  title: "Storage",
298
- ref: "",
304
+ ref: "INV-0031",
299
305
  charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
300
306
  },
301
307
  ];
302
308
 
309
+ /** A settled credit against the record — the statement's third side, and its only line. */
310
+ const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
311
+
303
312
  const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
304
313
  const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
305
314
 
@@ -640,6 +649,38 @@ interface Stop {
640
649
  * The kit draws the rail, the reorder/remove controls and the spacing; the
641
650
  * template supplies what a stop CONTAINS and what its position MEANS.
642
651
  */
652
+ /**
653
+ * What sits behind ONE invoice figure in the closing statement — the charges that add up
654
+ * to it, and the lookup link once it is issued.
655
+ *
656
+ * The link belongs in HERE rather than beside the row: a `peek` row is already a button,
657
+ * and a button inside a button is invalid markup. That is also why the flat `reference`
658
+ * link and `peek` are alternatives, never companions — the component renders `reference`
659
+ * only on a row that is not a door.
660
+ *
661
+ * Unpriced charges are listed too, as "—" rather than 0: a charge nobody has priced is
662
+ * not a charge of nothing, and this is the popover that explains why the figure above is
663
+ * smaller than the invoice's line count suggests.
664
+ */
665
+ function InvoiceParticulars({ inv }: { inv: Invoice }) {
666
+ return (
667
+ <View style={{ gap: 8, padding: 12, minWidth: 240 }}>
668
+ <Text size="xs" color="muted">{inv.title}</Text>
669
+ {inv.charges.map((c) => (
670
+ <View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
671
+ <Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
672
+ <Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
673
+ {c.amount > 0 ? formatMoney(c.amount) : "—"}
674
+ </Text>
675
+ </View>
676
+ ))}
677
+ {inv.ref ? (
678
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
679
+ ) : null}
680
+ </View>
681
+ );
682
+ }
683
+
643
684
  function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
644
685
  const patch = (id: string, next: Partial<Stop>) =>
645
686
  onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
@@ -691,7 +732,9 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
691
732
  <View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
692
733
  {/* A stop is added BEFORE the destination — a new leg is always in the
693
734
  middle of a journey; nobody adds one past the end. */}
694
- <TextButton
735
+ <Button
736
+ title="Add stop"
737
+ color="secondary"
695
738
  onPress={() =>
696
739
  onChange([
697
740
  ...stops.slice(0, -1),
@@ -699,9 +742,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
699
742
  stops[stops.length - 1],
700
743
  ])
701
744
  }
702
- >
703
- Add stop
704
- </TextButton>
745
+ />
705
746
  </View>
706
747
  </View>
707
748
  );
@@ -1282,6 +1323,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1282
1323
  // total that will actually be billed, including a charge that landed after the press.
1283
1324
  const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1284
1325
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1326
+ // A charge carrying a METHOD has been paid — the statement below is derived from
1327
+ // that, not from a second "paid" flag somebody has to keep in step with it. The
1328
+ // record already knows; asking again is how the two answers start disagreeing.
1329
+ //
1330
+ // ONE list, and both the rows and the subtotal read it. Deriving them from two
1331
+ // separate predicates is how a ledger comes to disagree with itself: filter the
1332
+ // rows on `method && amount > 0` while summing on `method` alone and any charge
1333
+ // that fails only the second test lands in the total with no line to explain it.
1334
+ // The component cannot catch that — it renders the `total` it is handed.
1335
+ const receipts = invoices.flatMap((inv) =>
1336
+ inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
1337
+ );
1338
+ const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
1339
+ // Three sides now, so the total states the arithmetic rather than a difference of two:
1340
+ // what was billed, less what was credited, less what arrived.
1341
+ const outstanding = grandTotal - CREDIT.amount - received;
1285
1342
  const allMissing = invoices.flatMap(missingMethods);
1286
1343
 
1287
1344
  const issue = (inv: Invoice) => {
@@ -2441,12 +2498,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2441
2498
  convention, which is the kit's. It replaces the
2442
2499
  DrawerFooter that used to pin it.
2443
2500
 
2444
- `TextButton`, same as the peek's detach: a text-weight
2445
- destructive act aligned to a column of text. It lands its
2446
- ink ON the label column by construction, keeps a 40px
2447
- target via hitSlop, and drops the trash glyph "Remove
2448
- fee" already names the object, which is the safeguard the
2449
- icon was only decorating.
2501
+ Solid `danger`, which is the destructive convention
2502
+ page-wide (composition rule 9) not a text-weight act.
2503
+ A quiet destructive verb had been tried here and the
2504
+ surface-less rung is gone: an act now always carries a
2505
+ control, and only NAVIGATION is underlined text. The trash
2506
+ glyph stays off — "Remove fee" already names the object,
2507
+ which is the safeguard the icon was only decorating.
2450
2508
 
2451
2509
  NO Divider. Every hairline a `Table` draws is
2452
2510
  full-bleed (the row `Divider`s carry no padding, the
@@ -2457,7 +2515,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2457
2515
  wrapper is required, not decoration: a `Button` alone
2458
2516
  in a column View stretches to full width. */}
2459
2517
  <View style={{ flexDirection: "row" }}>
2460
- <TextButton color="danger" onPress={() => deleteFee(f)}>Remove fee</TextButton>
2518
+ <Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
2461
2519
  </View>
2462
2520
  </View>
2463
2521
  }
@@ -2591,6 +2649,88 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2591
2649
  })}
2592
2650
  </SubsectionStack>
2593
2651
 
2652
+ {/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
2653
+ the one question the bands above cannot give: what does this record come
2654
+ to, and how much of it has arrived. Each invoice states its own total on
2655
+ its lead line, but three totals down the page are three facts the reader
2656
+ has to add up, and "is anything still owed" was nowhere on the section.
2657
+
2658
+ It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
2659
+ closes the invoices, so it sits directly under them and above the record's
2660
+ own fields — a conclusion goes after what it concludes.
2661
+
2662
+ THREE sides, which is what makes the total state arithmetic rather than a
2663
+ difference: billed, less credited, less received. Money coming back is
2664
+ NEGATIVE (the component renders "− ") and `success`-toned, because a
2665
+ receipt and a credit are both the good outcome; `zeroLabel` gives a settled
2666
+ record "Paid in full" instead of a proud zero.
2667
+
2668
+ The DEPOSIT is deliberately absent. It is refundable — never part of the
2669
+ total to collect — so folding it in would make the closing figure answer a
2670
+ different question than the one it is labelled with. A ledger earns trust
2671
+ by summing exactly what its label claims.
2672
+
2673
+ A row opens where there is something to open, and the two ways are not
2674
+ interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
2675
+ its one figure — and the lookup link lives INSIDE that popover, never beside
2676
+ the trigger, because a button inside a button is invalid. `reference` is the
2677
+ flat alternative for a row with nothing to expand: one charge IS its own
2678
+ particulars, so Storage carries a trailing link instead of a door.
2679
+
2680
+ The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
2681
+ single line states the same fact twice, at two weights. That is the honest
2682
+ fixture rather than a tidy one — real groups are built from data, so some
2683
+ arrive with one row — and it puts the grammar's open question on screen: an
2684
+ unsummed group captions from ABOVE while a summed one closes from below, so
2685
+ this statement names its groups in two places. */}
2686
+ <Ledger formatValue={money}>
2687
+ <LedgerGroup label="Invoiced" total={grandTotal}>
2688
+ {invoices.map((inv) => {
2689
+ /* NO meta on this side. It briefly carried "awaiting payment method",
2690
+ and that was `meta` doing two jobs: on the Received rows it holds a
2691
+ neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
2692
+ Two unlike things in one treatment is what makes a caption read as
2693
+ inconsistent — the size was never the fault.
2694
+ The problem also had a home already: the charge row above carries
2695
+ `warning="Choose how this was paid"`, on the line with the editor that
2696
+ fixes it. A statement's job is the arithmetic, and the arithmetic
2697
+ already says it — `Outstanding` IS the unpaid money. Saying it again in
2698
+ weaker words adds a second voice for one fact. */
2699
+ /* A door where there are PARTICULARS, and the count that decides it is the
2700
+ charges, not the priced ones. An invoice whose second line is unpriced is
2701
+ exactly the row whose figure looks too small for it, which is the question
2702
+ the popover answers — gating on `amount > 0` would hide the door precisely
2703
+ when it is most useful. */
2704
+ return (
2705
+ <LedgerRow
2706
+ key={inv.key}
2707
+ label={inv.title}
2708
+ value={invoiceTotal(inv)}
2709
+ peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
2710
+ reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
2711
+ />
2712
+ );
2713
+ })}
2714
+ </LedgerGroup>
2715
+ <LedgerGroup label="Adjustments">
2716
+ <LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
2717
+ </LedgerGroup>
2718
+ {received > 0 ? (
2719
+ <LedgerGroup label="Received" total={-received}>
2720
+ {receipts.map(({ inv, charge }) => (
2721
+ <LedgerRow
2722
+ key={`${inv.key}-${charge.key}`}
2723
+ label={charge.label}
2724
+ meta={METHODS.find((m) => m.value === charge.method)?.label}
2725
+ value={-charge.amount}
2726
+ tone="success"
2727
+ />
2728
+ ))}
2729
+ </LedgerGroup>
2730
+ ) : null}
2731
+ <LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
2732
+ </Ledger>
2733
+
2594
2734
  {/* ONE action row for the section, on the control column. The deposit
2595
2735
  is a field of the record, not a band of its own — its receipt is a
2596
2736
  second act on the same row. */}
@@ -1,7 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { TextButton } from "@lotics/ui/text_button";
5
4
  import { colors, solid, asColorName } from "@lotics/ui/colors";
6
5
  import { Icon } from "@lotics/ui/icon";
7
6
  import { CheckCircle } from "@lotics/ui/check_circle";
@@ -527,7 +526,7 @@ function RowActionCell({ task, result, onAttach, onApprove }: { task: Task; resu
527
526
  const onPress = a.kind === "attach" ? () => onAttach(task.id) : a.kind === "approve" ? () => onApprove(task.id) : () => {};
528
527
  return (
529
528
  <View style={styles.action}>
530
- <TextButton numberOfLines={1} onPress={onPress}>{a.label}</TextButton>
529
+ <Button title={a.label} color="muted" onPress={onPress} />
531
530
  </View>
532
531
  );
533
532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "26.4.1",
3
+ "version": "27.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -179,7 +179,6 @@
179
179
  "./counter": "./src/counter.tsx",
180
180
  "./link": "./src/link.tsx",
181
181
  "./reference_field": "./src/reference_field.tsx",
182
- "./text_button": "./src/text_button.tsx",
183
182
  "./text_link": "./src/text_link.tsx",
184
183
  "./sort_header": "./src/sort_header.tsx",
185
184
  "./skeleton": "./src/skeleton.tsx",
@@ -3,11 +3,11 @@ import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
6
- import { TextButton } from "./text_button";
7
6
  import { Chip } from "./chip";
8
7
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
8
  import type { PopoverSide, PopoverAlign } from "./popover";
10
9
  import { useLoticsLocale } from "./locale";
10
+ import { Button } from "./button";
11
11
 
12
12
  export interface FilterChipProps {
13
13
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
@@ -110,7 +110,7 @@ export function FilterChip(props: FilterChipProps) {
110
110
  <PopoverFooter>{footer}</PopoverFooter>
111
111
  ) : showClear ? (
112
112
  <PopoverFooter align="start">
113
- <TextButton onPress={onClear}>{clearLabel}</TextButton>
113
+ <Button title={clearLabel} color="muted" onPress={onClear} />
114
114
  </PopoverFooter>
115
115
  ) : null}
116
116
  </PopoverContent>
package/src/ledger.tsx CHANGED
@@ -9,9 +9,12 @@ import { useLoticsLocale } from "./locale";
9
9
  import { Text } from "./text";
10
10
 
11
11
  // The record-level money list — a compact financial STATEMENT for one record's
12
- // drawer/section: charge/receipt GROUPS with their sums in the group headers,
13
- // every figure on ONE right-aligned tabular column, closed by a divider-set
14
- // emphasized total. A row with `peek` is a door: pressing it floats the fee's
12
+ // drawer/section: charge/receipt GROUPS each closed by their own sum, every
13
+ // figure on ONE right-aligned tabular column, closed by a divider-set
14
+ // emphasized total. Three ascending rungs and no more a row, a group's
15
+ // subtotal, the total — so the reader's eye lands on the conclusion.
16
+ //
17
+ // A row with `peek` is a door: pressing it floats the fee's
15
18
  // PARTICULARS (basis, who charged it, the invoice it landed on) in an anchored
16
19
  // popover — the list stays flat and scannable, the depth is on demand. A row
17
20
  // with `reference` carries a trailing link instead (an issued invoice, a
@@ -63,31 +66,125 @@ export function Ledger(props: LedgerProps) {
63
66
  }
64
67
 
65
68
  export interface LedgerGroupProps {
66
- /** The group name ("Charges", "Received") xs muted, with the sum opposite. */
69
+ /** The group name ("Charges", "Received", a category). It travels WITH the sum when
70
+ * there is one, and captions the rows from above when there is not. */
67
71
  label: string;
68
- /** The group's sum, shown right-aligned in the header. Sign renders as
69
- * "−" (pass receipts negative). Omit to leave the header sumless. */
72
+ /** The group's sum, closing the rows BELOW it. Sign renders as "−" (pass receipts
73
+ * negative). It must EQUAL the rows it closes — derive the rows and the sum from one
74
+ * list, never from two predicates that can drift apart.
75
+ *
76
+ * Omit it for a group that only NAMES its rows: a sum earns its place by adding
77
+ * something the rows do not already say, which a one-row group's sum does not. */
70
78
  total?: number;
71
79
  children: ReactNode;
72
80
  }
73
81
 
74
- /** One side of the ledger — a labelled run of rows with its sum in the header. */
82
+ /**
83
+ * One side of the ledger — a run of rows, closed by its sum.
84
+ *
85
+ * **The sum FOLLOWS its rows.** It sat in the header for a version, which is how a
86
+ * caption behaves, not how a subtotal does: every invoice, statement and receipt puts
87
+ * a subtotal after the lines it sums, and above them the reader either trusts a figure
88
+ * before seeing its parts or reads down and jumps back up to find it.
89
+ *
90
+ * It also sat at the LABEL's weight — xs muted — so the number summarising three rows
91
+ * came out smaller and greyer than the rows themselves. A subtotal is of the same
92
+ * magnitude class as its lines, so it takes their SIZE and one step of weight above
93
+ * them: `sm/medium` against rows at `sm/regular`, under a total at `md/semibold`. Three
94
+ * rungs, each one step, and the eye can rank them without reading a word.
95
+ *
96
+ * The LABEL travels with the sum rather than being repeated: a group that closes with
97
+ * "Invoiced 1.430.000 ₫" does not also need to announce "INVOICED" before its rows —
98
+ * that is the same word twice for one group. A group with no `total` therefore keeps the
99
+ * caption ABOVE its rows, because there is nothing for its label to travel with — and a
100
+ * group can legitimately have no sum: naming a category is one job, summing it is
101
+ * another, and a one-row group's sum only restates the row.
102
+ *
103
+ * **Do not mix the two inside one `Ledger`.** The label sits above an unsummed group and
104
+ * below a summed one, so a statement holding both moves its own group names around and
105
+ * reads as though the placement were an accident. Pick one shape per statement: if any
106
+ * group earns a sum, give every group one (grouping a single row is then the thing to
107
+ * avoid — let that row stand bare, or fold it into a neighbouring group).
108
+ *
109
+ * Naming a side at the BOTTOM means the reader meets its rows before its name, so the
110
+ * rows must say which side they are on by themselves — which they do when the sides
111
+ * carry opposite SIGNS (charges positive, receipts "−" and `tone="success"`). Two
112
+ * same-signed groups are not two sides of one statement; they are two statements, and
113
+ * they want two `Ledger`s under their own headings.
114
+ */
75
115
  export function LedgerGroup(props: LedgerGroupProps) {
76
116
  const { label, total, children } = props;
77
117
  const { format } = useLedger();
118
+ const closed = total != null;
78
119
  return (
79
120
  <View style={styles.group}>
80
- <View style={styles.groupHead}>
81
- <Text size="xs" weight="medium" color="muted" style={styles.grow}>
82
- {label}
83
- </Text>
84
- {total != null ? (
85
- <Text size="xs" color="muted" tabular>
121
+ {!closed ? (
122
+ <View style={styles.groupHead}>
123
+ <Text size="xs" weight="medium" color="muted" style={styles.grow}>
124
+ {label}
125
+ </Text>
126
+ </View>
127
+ ) : null}
128
+ {children}
129
+ {closed ? (
130
+ <View style={styles.row}>
131
+ <Text size="sm" weight="medium" style={styles.grow}>
132
+ {label}
133
+ </Text>
134
+ <Text size="sm" weight="medium" tabular>
86
135
  {signed(format, total)}
87
136
  </Text>
88
- ) : null}
137
+ </View>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
142
+
143
+ export interface LedgerBasisProps {
144
+ /** What the figure IS ("Customs value (CIF)", "Gross revenue", "Principal"). */
145
+ label: string;
146
+ value: number;
147
+ /** How the figure was arrived at — the FX rate it was converted at, the period
148
+ * it covers. Not a second label: it qualifies the number, it does not name it. */
149
+ meta?: string;
150
+ }
151
+
152
+ /**
153
+ * The figure the rows below are computed FROM — a statement's premise, not one of
154
+ * its lines.
155
+ *
156
+ * Some statements are not `charges → total`. Duty is `customs value → levies →
157
+ * payable`; commission is `gross → rates → owed`; interest is `principal → accrual
158
+ * → balance`. In all of them one number is the BASE and the rest are derived from
159
+ * it, and a ledger that can only say "row" has to lie about which is which.
160
+ *
161
+ * Rendering the base as a `LedgerRow` claims it is a peer of the levies — that you
162
+ * could add it to them. Putting it in a one-row `LedgerGroup` is worse: a group's
163
+ * sum earns its place only when it sums more than one visible row, so a single-row
164
+ * group states the same fact twice, at two weights, and the reader has to work out
165
+ * that the repetition means nothing.
166
+ *
167
+ * So it takes the THIRD treatment on the ledger's one money column — between a
168
+ * plain row and the total — and its `Divider` sits BELOW it, because a premise
169
+ * separates itself from what it feeds. (`LedgerTotal`'s rule sits above, closing
170
+ * what came before.) Order is the caller's: a basis first reads as "from this",
171
+ * which is the only order that makes the levies beneath it legible.
172
+ */
173
+ export function LedgerBasis(props: LedgerBasisProps) {
174
+ const { label, value, meta } = props;
175
+ const { format } = useLedger();
176
+ return (
177
+ <View style={styles.basis}>
178
+ <View style={[styles.row, styles.baselineRow]}>
179
+ <View style={styles.grow}>
180
+ <Text size="sm" weight="medium">{label}</Text>
181
+ {meta != null ? <Text size="xs" color="muted">{meta}</Text> : null}
182
+ </View>
183
+ <Text size="sm" weight="medium" tabular>
184
+ {signed(format, value)}
185
+ </Text>
89
186
  </View>
90
- {children}
187
+ <Divider />
91
188
  </View>
92
189
  );
93
190
  }
@@ -95,7 +192,18 @@ export function LedgerGroup(props: LedgerGroupProps) {
95
192
  export interface LedgerRowProps {
96
193
  /** The line's name — what was charged / received. */
97
194
  label: string;
98
- /** Inline context after the label — a date, method, a basis, a period. */
195
+ /**
196
+ * Inline context after the label — a date, a method, a basis, a period. A neutral
197
+ * QUALIFIER on the figure, rendered as a caption (xs, muted) because it is
198
+ * subordinate to the thing it qualifies.
199
+ *
200
+ * Never a PROBLEM or a state ("awaiting approval", "missing method"). It reads
201
+ * inconsistent the moment one row's meta is a fact and another's is a complaint —
202
+ * two unlike things in one treatment — and a caption is the wrong weight for
203
+ * something that wants acting on. A problem belongs on the row that can FIX it (a
204
+ * `DetailRow`'s `warning` beside its editor), and in a statement the arithmetic has
205
+ * usually said it already: an unpaid line is what the outstanding total IS.
206
+ */
99
207
  meta?: string;
100
208
  /** The amount. Negative renders "− <abs>" (a receipt). */
101
209
  value: number;
@@ -174,7 +282,23 @@ export interface LedgerTotalProps {
174
282
  zeroLabel?: string;
175
283
  }
176
284
 
177
- /** The divider-set closing line — the ledger's ONE emphasized number. */
285
+ /**
286
+ * The divider-set closing line — the ledger's ONE emphasized number.
287
+ *
288
+ * **Both sides take the same size.** The figure spent a version at `lg` against an
289
+ * `sm` label, which on web is 20px beside 14px (the scale steps `lg` up past 768px) —
290
+ * a 1.4× mismatch between two texts that are PEERS on one line, and a mismatch on one
291
+ * line reads as a slip however principled its reason. Raising the label to match is
292
+ * worse: `lg` puts it within a few px of the section heading above it and the record's
293
+ * conclusion starts competing with the record's structure.
294
+ *
295
+ * So the line is `md/semibold` throughout, and the emphasis comes from everything
296
+ * except size: it is the only line above a `Divider`, the only one at semibold, and
297
+ * the only one allowed a tone. Against rows at `sm/regular` and subtotals at
298
+ * `sm/medium` that is still a clear third rung — 2px and a weight step, plus a rule
299
+ * and a colour — and the ledger ends up using exactly two type sizes for its money
300
+ * and one for its captions.
301
+ */
178
302
  export function LedgerTotal(props: LedgerTotalProps) {
179
303
  const { label, value, tone = "default", zeroLabel } = props;
180
304
  const { format } = useLedger();
@@ -183,10 +307,10 @@ export function LedgerTotal(props: LedgerTotalProps) {
183
307
  <View style={styles.total}>
184
308
  <Divider />
185
309
  <View style={styles.row}>
186
- <Text size="sm" weight="semibold" style={styles.grow}>
310
+ <Text size="md" weight="semibold" style={styles.grow}>
187
311
  {label}
188
312
  </Text>
189
- <Text size="lg" weight="semibold" tabular color={settled ? "success" : tone === "default" ? undefined : tone}>
313
+ <Text size="md" weight="semibold" tabular color={settled ? "success" : tone === "default" ? undefined : tone}>
190
314
  {settled ? zeroLabel : signed(format, value)}
191
315
  </Text>
192
316
  </View>
@@ -199,13 +323,15 @@ const styles = StyleSheet.create({
199
323
  // the money column align with the section heading and the container edges;
200
324
  // the pressable door's wash bleeds into the gutter instead of squeezing text.
201
325
  // Grouping reads by PROXIMITY: the gap between groups must be decisively larger
202
- // than the gap inside one, or the eyebrow floats midway and belongs to neither
203
- // the group above nor the rows below. 18 between, 2 within.
326
+ // than the gap inside one, or a line floats midway and belongs to neither the
327
+ // group above nor the rows below. 16 between, 0 within — a row's own 28px
328
+ // minHeight already sets the beat inside a group, so adding a gap on top of it
329
+ // would loosen the group toward the between-group distance and undo the ranking.
204
330
  ledger: { gap: 16, marginHorizontal: -8 },
205
331
  group: { gap: 0 },
206
- // EVERY line (group header, rows, the total) shares the 8px text inset, so
207
- // labels and the money column sit on one edge whether a row peeks or not —
208
- // the pressable door's wash simply fills the same padded box.
332
+ // EVERY line (rows, a group's caption or sum, the basis, the total) shares the 8px
333
+ // text inset, so labels and the money column sit on one edge whether a row peeks or
334
+ // not — the pressable door's wash simply fills the same padded box.
209
335
  row: {
210
336
  flexDirection: "row",
211
337
  alignItems: "center",
@@ -213,9 +339,8 @@ const styles = StyleSheet.create({
213
339
  minHeight: 28,
214
340
  paddingHorizontal: 8,
215
341
  },
216
- // The group eyebrow is a CAPTION on the rows beneath it, not a line of its own —
217
- // it takes only the height its xs text needs, so it hugs what it labels instead
218
- // of sitting in a full 28px row box.
342
+ // An unsummed group's caption labels the rows beneath it, so it takes only the height
343
+ // its xs text needs hugging what it labels instead of sitting in a full 28px row.
219
344
  groupHead: {
220
345
  flexDirection: "row",
221
346
  alignItems: "center",
@@ -230,4 +355,16 @@ const styles = StyleSheet.create({
230
355
  grow: { flexGrow: 1, flexShrink: 1 },
231
356
  shrink: { flexShrink: 1 },
232
357
  total: { gap: 6 },
358
+ // The BASIS row, the only one whose two sides can differ in height: its label may
359
+ // carry a stacked `meta` while the figure stays one line. Centring then measures the
360
+ // figure against the whole block and lands it in the gap between the label and its
361
+ // caption instead of level with the label it belongs to. On a shared baseline the
362
+ // figure sits on the label's FIRST line — the law `DetailRow` already follows for a
363
+ // wrapped label, and the alignment `KPICard`, `RemainderMeter`, `ProgressBar` and
364
+ // `Funnel` already use wherever a figure meets smaller text on one row. Every other
365
+ // line here is same-size on both sides, where centring and baseline agree.
366
+ baselineRow: { alignItems: "baseline" },
367
+ // Mirrors `total`'s gap so the premise and the conclusion frame the levies with
368
+ // the same air — the rule just sits on the other side.
369
+ basis: { gap: 6 },
233
370
  });
@@ -7,7 +7,6 @@ import { Checkbox } from "./checkbox";
7
7
  import { MenuButton } from "./menu_button";
8
8
  import { MenuListItem } from "./menu_list_item";
9
9
  import { Button } from "./button";
10
- import { TextButton } from "./text_button";
11
10
  import { ActivityIndicator } from "./activity_indicator";
12
11
  import { TextInputField } from "./text_input_field";
13
12
  import { useScreenSize } from "./use_screen_size";
@@ -176,9 +175,9 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
176
175
 
177
176
  {list.showSelectAll || list.showDeselectAll ? (
178
177
  <View style={styles.selectAllContainer}>
179
- {list.showSelectAll ? <TextButton onPress={list.selectAll}>{selectAllLabel}</TextButton> : null}
178
+ {list.showSelectAll ? <Button title={selectAllLabel} color="muted" onPress={list.selectAll} /> : null}
180
179
  {list.showDeselectAll ? (
181
- <TextButton onPress={list.deselectAll}>{deselectAllLabel}</TextButton>
180
+ <Button title={deselectAllLabel} color="muted" onPress={list.deselectAll} />
182
181
  ) : null}
183
182
  </View>
184
183
  ) : null}
@@ -200,8 +199,12 @@ const styles = StyleSheet.create({
200
199
  alignItems: "center",
201
200
  gap: 16,
202
201
  paddingVertical: 4,
203
- // Matches a MenuButton option's 8px text inset so the select-all / deselect-all
204
- // links line up under the option labels above.
202
+ // Matches a MenuButton option's 8px inset, so the Button's SURFACE starts on the
203
+ // same edge as the option rows above it. Not its ink: a Button's ink sits 10px
204
+ // further in (measured 458 against option labels at 480, which begin after the
205
+ // checkbox), and no inset here makes text-to-text alignment possible in a list
206
+ // whose rows lead with a control. The surface is the edge a surfaced control
207
+ // aligns on.
205
208
  paddingHorizontal: 8,
206
209
  borderTopWidth: 1,
207
210
  borderTopColor: colors.border,
package/src/select.tsx CHANGED
@@ -226,12 +226,27 @@ function SelectTrigger<T extends string>({
226
226
  <FocusRingPressable
227
227
  ref={ref}
228
228
  testID={testID}
229
- // Without a role this Pressable renders as an unfocusable <div> on web — the
230
- // trigger drops out of the tab order and Enter/Space can't open it. `button`
231
- // makes it tab-focusable and maps keyboard activation to onPress; `aria-expanded`
232
- // announces open/closed to assistive tech. The W3C props, NOT `accessibilityState`
233
- // this react-native-web build drops the latter silently.
234
- accessibilityRole="button"
229
+ // `combobox`, NOT `button`, and the reason is structural rather than semantic.
230
+ // A role is what react-native-web picks the ELEMENT from, and `button` gave a real
231
+ // <button> whose content model forbids interactive descendants. The sanctioned
232
+ // chip-box (`renderSelected` `<Chip onDismiss>`, the reason there is no separate
233
+ // TagInput) puts a remove button inside the trigger, so the trigger rendered a
234
+ // <button> inside a <button>: invalid HTML, a React error on every mount, and a
235
+ // removal control of doubtful reachability. `combobox` renders a <div>, which may
236
+ // legally contain buttons, and it is the role the APG pattern this control already
237
+ // follows actually calls for (docs/accessibility.md: a popup opens on Enter/Space,
238
+ // never on Tab arrival). Verified on the chip demo: tab-focusable (tabindex 0),
239
+ // Enter opens, pointer opens, Escape closes, and the ✕ removes without opening.
240
+ //
241
+ // Without any role this Pressable would render an unfocusable <div> — out of the
242
+ // tab order, no keyboard activation. `aria-expanded` announces open/closed. The
243
+ // W3C props, NOT `accessibilityState` — this build drops the latter silently.
244
+ //
245
+ // When `searchable`, OptionList's filter input is ALSO a combobox (it owns
246
+ // aria-controls + aria-activedescendant, the editable half of the pattern). Two
247
+ // combobox-shaped controls across two focus events is redundant, not wrong — and
248
+ // strictly better than the invalid nesting it replaced.
249
+ accessibilityRole="combobox"
235
250
  accessibilityLabel={accessibilityLabel}
236
251
  aria-expanded={open}
237
252
  aria-disabled={disabled}
@@ -32,6 +32,20 @@ export interface SummaryLineProps {
32
32
  * dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
33
33
  * pages, left untouched). Here there's no card, no big numbers, no column rules
34
34
  * — a small `Metric` value + a muted label, items wrapping, separated by space.
35
+ *
36
+ * **It summarizes a SET, and it goes with the set it summarizes.** Every item must be
37
+ * an aggregate over the rows in view — a count, a sum, a fill — so the strip earns its
38
+ * place by answering something no single row can. Nesting counts as a set: a record
39
+ * that gathers children (a consol of house shipments, an order of lines) may carry one
40
+ * above THAT list.
41
+ *
42
+ * **Never on a record's identity band, reporting that record's own state.** Derived
43
+ * verdicts about one record — "documents incomplete", "2 fields need checking", "no
44
+ * next milestone" — are a checklist, and a record does not carry a checklist
45
+ * (`docs/catalog.md`); work state belongs to `Pipeline`, a field's problem to a
46
+ * `Callout` on the field that can fix it. Such a strip also restates at a distance
47
+ * what the sections below state in place, so it goes stale against its own record and
48
+ * gives the reader two versions of one truth.
35
49
  */
36
50
  export function SummaryLine(props: SummaryLineProps) {
37
51
  const { items } = props;
package/src/text_link.tsx CHANGED
@@ -20,11 +20,15 @@ export interface TextLinkProps extends TextProps {
20
20
  * "this opens somewhere else" signal.
21
21
  *
22
22
  * It does NOT act. An `onPress` here used to resolve to `role="button"`, so one
23
- * export was three components — a link, a button, and a passive marker — behind
24
- * one name, inside `OptionList`, `FilterChip` and others. That mode is
25
- * `TextButton` now — also underlined, since that is what marks a surface-less
26
- * control interactive, but in ZINC: blue is reserved for navigation, so the ink is
27
- * what says whether a press moves you or acts. Pick by what the press DOES.
23
+ * export was three components — a link, a button, and a passive marker — behind one
24
+ * name, inside `OptionList`, `FilterChip` and others.
25
+ *
26
+ * The act mode briefly became its own surface-less component and is now gone
27
+ * entirely: it made underline mean two things separable only by ink, and it competed
28
+ * with the `Button` colour ladder for the job `muted` already does. **Underlined text
29
+ * is the NAVIGATION affordance; anything that acts carries a control surface** —
30
+ * `Button` in chrome, `InlineButton` on a field. A quiet verb that cannot sit against
31
+ * a text column belongs in chrome, not in a rung of its own.
28
32
  */
29
33
  export function TextLink(props: TextLinkProps) {
30
34
  const { icon, href, children, color, style, ...textProps } = props;
@@ -1,139 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { StyleSheet } from "react-native";
3
- import { PressableHighlight } from "./pressable_highlight";
4
- import { Text } from "./text";
5
-
6
- /** Two only. Deliberately NOT the whole `TextColor` palette: `muted` would mute the
7
- * one thing carrying the act's weight, and the rest (warning/success/inverted) name
8
- * states, not verbs. Reach for `Button color="muted"` when an act must be quieter. */
9
- export type TextButtonColor = "default" | "danger";
10
-
11
- export interface TextButtonProps {
12
- /** The verb, as words. */
13
- children: ReactNode;
14
- /** The act. REQUIRED — a text button with nothing to do is just `Text`. */
15
- onPress: () => void;
16
- /** `default` = neutral ink; `danger` = destructive. */
17
- color?: TextButtonColor;
18
- /**
19
- * Nothing to act on. Pass it UNCONDITIONALLY and disable it rather than
20
- * rendering the verb only once its target exists — an action that appears and
21
- * disappears reflows the line it sits in, which is the one thing a control in
22
- * a row of text must never do.
23
- */
24
- disabled?: boolean;
25
- /** Announced name, when the words alone are ambiguous ("Open" → "Open customer"). */
26
- accessibilityLabel?: string;
27
- /** Truncate at this many lines (a label in a narrow row). */
28
- numberOfLines?: number;
29
- tooltip?: string;
30
- }
31
-
32
- /**
33
- * A low-chrome ACTION rendered at text weight — "Select all", "Clear",
34
- * "Add stop". The third member of the action family, by CHROME:
35
- * `Button` (a 40px control with a surface) → `InlineButton` (28px, filled, living
36
- * on a field's own surface) → this (no surface AT REST, sitting in a line of text).
37
- *
38
- * UNDERLINED, in NEUTRAL ink — what Airbnb ships for a text action, and the shape
39
- * that keeps this kit's own colour rule intact:
40
- *
41
- * underline = INTERACTIVE blue = NAVIGATION
42
- * `Link`/`TextLink` blue + underline → navigates
43
- * `TextButton` zinc + underline → acts
44
- *
45
- * The underline is the affordance and it is not optional. With no surface and no
46
- * tint, nothing else says at rest that the words can be pressed: POSITION is a
47
- * learned convention rather than an affordance (invisible to a first-time reader),
48
- * and hover is not one either — absent on touch, and revealed only once you are
49
- * already there. A tint is the other way to say it, the one Material and HIG take,
50
- * but blue is spoken for here and a second accent for "actionable" would collide
51
- * with the one-accent-per-purpose rule.
52
- *
53
- * This is also why it holds INSIDE a run of prose, where colour alone could not
54
- * distinguish an embedded control at all (WCAG 1.4.1 / F73).
55
- *
56
- * Built on `PressableHighlight` rather than a pressable `Text`, because a `Text`
57
- * with `onPress` gets NEITHER hover nor a focus ring: it renders a real
58
- * `<button>` with `tabIndex=0`, `outline: none` and no box-shadow, so a keyboard
59
- * user lands on it with no indication they have. A focusable control with no focus
60
- * treatment is a bug, and hand-rolling hover + focus onto `Text` would duplicate
61
- * machinery this already owns. The inherited hover wash is Material's state layer
62
- * by another name, and it costs nothing at rest, so the chrome ladder above holds.
63
- *
64
- * The padding/negative-margin bleed is `Peek`'s: the wash and the focus ring need
65
- * room off the glyphs, and the margins give it back so the line's layout never
66
- * shifts — `OptionList` depends on that, insetting 8px so its select-all verbs
67
- * line up under the option labels above.
68
- *
69
- * ONE SIZE (`Text`'s own `sm`) and no icon: it sits in a line of text and matches
70
- * it. A verb needing an icon or a real hit target is a `Button`.
71
- *
72
- * It is TEXT-height, not control-height, and that is deliberate: `marginVertical`
73
- * absorbs the touch box so the height IN FLOW stays 24 and dropping one into a
74
- * dense band (`OptionList`'s select-all, `FilterChip`'s Clear) cannot grow that
75
- * band. Do NOT match `Button`'s 40 to make a mixed row line up — that inflates
76
- * every inline use and collapses the chrome ladder above, turning this into a
77
- * Button without a fill. A row that mixes rungs aligns them the way such a row
78
- * should anyway: `alignItems: "center"`, which lands a centred 32px box's text on
79
- * the same line as a centred 40px one (measured: 0px apart in a peek footer).
80
- *
81
- * It sets NO `alignSelf`, so it obeys its parent — which is what a row wants, and
82
- * a baked `flex-start` top-aligned it against a taller sibling and silently
83
- * overrode a footer's `alignItems: center`. In a COLUMN container a `Pressable`
84
- * stretches, so the hover wash would run the full width for a two-word verb: wrap
85
- * it in a `flexDirection: "row"` View there, the same thing a `Button` needs.
86
- */
87
- export function TextButton(props: TextButtonProps) {
88
- const { children, onPress, color = "default", disabled, accessibilityLabel, numberOfLines, tooltip } = props;
89
- // `TextColor` tokens throughout, no raw palette access: neutral ink IS `default`,
90
- // and `zinc-400` is `Button`'s own disabled ink — an inert control should read the
91
- // same whatever its chrome, where muting to zinc-600 left it looking merely quiet.
92
- const ink = disabled === true ? "zinc-400" : color === "danger" ? "danger" : "default";
93
- return (
94
- <PressableHighlight
95
- focusRing
96
- accessibilityRole="button"
97
- accessibilityLabel={accessibilityLabel}
98
- aria-disabled={disabled === true ? true : undefined}
99
- disabled={disabled}
100
- tooltip={tooltip}
101
- // 32px box + 4px slop = the 40px target `Peek` keeps for the same shape. A
102
- // text action measured 24px on its own, which clears WCAG 2.5.8's 24×24
103
- // floor and nothing else — and it sits in dense rows, exactly where a thumb
104
- // needs the margin most.
105
- hitSlop={4}
106
- onPress={onPress}
107
- // After the inherited wash, so a disabled verb does not light up under the
108
- // pointer — `hovered` still fires on a disabled Pressable.
109
- style={[styles.trigger, disabled === true ? styles.dead : null]}
110
- >
111
- <Text decoration="underline" weight="medium" color={ink} numberOfLines={numberOfLines}>
112
- {children}
113
- </Text>
114
- </PressableHighlight>
115
- );
116
- }
117
-
118
- const styles = StyleSheet.create({
119
- trigger: {
120
- flexDirection: "row",
121
- alignItems: "center",
122
- // NO `alignSelf` here — see the prop. A baked `flex-start` is a CROSS-axis
123
- // instruction, so it means "hug the words" only in a column; in a row it means
124
- // top-align, and it silently overrode a footer's `alignItems: center`.
125
- borderRadius: 6,
126
- // Room for the wash + focus ring, given straight back as margin so adding a
127
- // TextButton to a line cannot move anything around it (`Peek`'s idiom). The
128
- // 32px box carries the touch target; `marginVertical` absorbs 8 of it, so the
129
- // height IN FLOW stays 24 and a row's rhythm is unchanged.
130
- minHeight: 32,
131
- marginVertical: -4,
132
- // 6 rather than `Peek`'s 8: these come in GROUPS (OptionList's select-all /
133
- // deselect-all sit 16 apart), and an 8px bleed each side would leave adjacent
134
- // washes touching.
135
- paddingHorizontal: 6,
136
- marginHorizontal: -6,
137
- },
138
- dead: { backgroundColor: "transparent" },
139
- });