@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.
@@ -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";
@@ -136,7 +136,11 @@ interface Customer {
136
136
  // inline Tax ID fix-up in the Customer section.
137
137
  const KNOWN_CUSTOMERS: Customer[] = [
138
138
  { id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden", since: "2019-03-14" },
139
- { id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
139
+ // DELIBERATELY too long for one line. A register of tidy two-word names cannot
140
+ // show what a reference does when it does not fit — which is truncate in the
141
+ // field and wrap in the peek — so the one fixture the template opens with is the
142
+ // realistic worst case. Real customer names look like this.
143
+ { id: "cus_02", name: "Harbor Freight Lines & Coastal Forwarding Group", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
140
144
  { id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803", since: "2023-06-19" },
141
145
  { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany", since: "2018-01-30" },
142
146
  { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium", since: "2022-09-08" },
@@ -278,9 +282,12 @@ interface Invoice {
278
282
 
279
283
  const BILLING_INITIAL: Invoice[] = [
280
284
  {
285
+ // Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
286
+ // so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
287
+ // Storage is the mirror case (one charge, issued) and takes the flat link instead.
281
288
  key: "delivery",
282
289
  title: "Delivery",
283
- ref: "",
290
+ ref: "INV-0029",
284
291
  charges: [
285
292
  { key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
286
293
  { key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
@@ -293,13 +300,19 @@ const BILLING_INITIAL: Invoice[] = [
293
300
  charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
294
301
  },
295
302
  {
303
+ // Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
304
+ // band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
305
+ // has happened yet only ever exercises the first half of a flow.
296
306
  key: "storage",
297
307
  title: "Storage",
298
- ref: "",
308
+ ref: "INV-0031",
299
309
  charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
300
310
  },
301
311
  ];
302
312
 
313
+ /** A settled credit against the record — the statement's third side, and its only line. */
314
+ const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
315
+
303
316
  const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
304
317
  const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
305
318
 
@@ -640,6 +653,38 @@ interface Stop {
640
653
  * The kit draws the rail, the reorder/remove controls and the spacing; the
641
654
  * template supplies what a stop CONTAINS and what its position MEANS.
642
655
  */
656
+ /**
657
+ * What sits behind ONE invoice figure in the closing statement — the charges that add up
658
+ * to it, and the lookup link once it is issued.
659
+ *
660
+ * The link belongs in HERE rather than beside the row: a `peek` row is already a button,
661
+ * and a button inside a button is invalid markup. That is also why the flat `reference`
662
+ * link and `peek` are alternatives, never companions — the component renders `reference`
663
+ * only on a row that is not a door.
664
+ *
665
+ * Unpriced charges are listed too, as "—" rather than 0: a charge nobody has priced is
666
+ * not a charge of nothing, and this is the popover that explains why the figure above is
667
+ * smaller than the invoice's line count suggests.
668
+ */
669
+ function InvoiceParticulars({ inv }: { inv: Invoice }) {
670
+ return (
671
+ <View style={{ gap: 8, padding: 12, minWidth: 240 }}>
672
+ <Text size="xs" color="muted">{inv.title}</Text>
673
+ {inv.charges.map((c) => (
674
+ <View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
675
+ <Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
676
+ <Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
677
+ {c.amount > 0 ? formatMoney(c.amount) : "—"}
678
+ </Text>
679
+ </View>
680
+ ))}
681
+ {inv.ref ? (
682
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
683
+ ) : null}
684
+ </View>
685
+ );
686
+ }
687
+
643
688
  function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
644
689
  const patch = (id: string, next: Partial<Stop>) =>
645
690
  onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
@@ -691,7 +736,9 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
691
736
  <View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
692
737
  {/* A stop is added BEFORE the destination — a new leg is always in the
693
738
  middle of a journey; nobody adds one past the end. */}
694
- <TextButton
739
+ <Button
740
+ title="Add stop"
741
+ color="secondary"
695
742
  onPress={() =>
696
743
  onChange([
697
744
  ...stops.slice(0, -1),
@@ -699,9 +746,7 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
699
746
  stops[stops.length - 1],
700
747
  ])
701
748
  }
702
- >
703
- Add stop
704
- </TextButton>
749
+ />
705
750
  </View>
706
751
  </View>
707
752
  );
@@ -1282,6 +1327,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1282
1327
  // total that will actually be billed, including a charge that landed after the press.
1283
1328
  const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
1284
1329
  const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
1330
+ // A charge carrying a METHOD has been paid — the statement below is derived from
1331
+ // that, not from a second "paid" flag somebody has to keep in step with it. The
1332
+ // record already knows; asking again is how the two answers start disagreeing.
1333
+ //
1334
+ // ONE list, and both the rows and the subtotal read it. Deriving them from two
1335
+ // separate predicates is how a ledger comes to disagree with itself: filter the
1336
+ // rows on `method && amount > 0` while summing on `method` alone and any charge
1337
+ // that fails only the second test lands in the total with no line to explain it.
1338
+ // The component cannot catch that — it renders the `total` it is handed.
1339
+ const receipts = invoices.flatMap((inv) =>
1340
+ inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
1341
+ );
1342
+ const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
1343
+ // Three sides now, so the total states the arithmetic rather than a difference of two:
1344
+ // what was billed, less what was credited, less what arrived.
1345
+ const outstanding = grandTotal - CREDIT.amount - received;
1285
1346
  const allMissing = invoices.flatMap(missingMethods);
1286
1347
 
1287
1348
  const issue = (inv: Invoice) => {
@@ -2441,12 +2502,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2441
2502
  convention, which is the kit's. It replaces the
2442
2503
  DrawerFooter that used to pin it.
2443
2504
 
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.
2505
+ Solid `danger`, which is the destructive convention
2506
+ page-wide (composition rule 9) not a text-weight act.
2507
+ A quiet destructive verb had been tried here and the
2508
+ surface-less rung is gone: an act now always carries a
2509
+ control, and only NAVIGATION is underlined text. The trash
2510
+ glyph stays off — "Remove fee" already names the object,
2511
+ which is the safeguard the icon was only decorating.
2450
2512
 
2451
2513
  NO Divider. Every hairline a `Table` draws is
2452
2514
  full-bleed (the row `Divider`s carry no padding, the
@@ -2457,7 +2519,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2457
2519
  wrapper is required, not decoration: a `Button` alone
2458
2520
  in a column View stretches to full width. */}
2459
2521
  <View style={{ flexDirection: "row" }}>
2460
- <TextButton color="danger" onPress={() => deleteFee(f)}>Remove fee</TextButton>
2522
+ <Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
2461
2523
  </View>
2462
2524
  </View>
2463
2525
  }
@@ -2591,6 +2653,88 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2591
2653
  })}
2592
2654
  </SubsectionStack>
2593
2655
 
2656
+ {/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
2657
+ the one question the bands above cannot give: what does this record come
2658
+ to, and how much of it has arrived. Each invoice states its own total on
2659
+ its lead line, but three totals down the page are three facts the reader
2660
+ has to add up, and "is anything still owed" was nowhere on the section.
2661
+
2662
+ It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
2663
+ closes the invoices, so it sits directly under them and above the record's
2664
+ own fields — a conclusion goes after what it concludes.
2665
+
2666
+ THREE sides, which is what makes the total state arithmetic rather than a
2667
+ difference: billed, less credited, less received. Money coming back is
2668
+ NEGATIVE (the component renders "− ") and `success`-toned, because a
2669
+ receipt and a credit are both the good outcome; `zeroLabel` gives a settled
2670
+ record "Paid in full" instead of a proud zero.
2671
+
2672
+ The DEPOSIT is deliberately absent. It is refundable — never part of the
2673
+ total to collect — so folding it in would make the closing figure answer a
2674
+ different question than the one it is labelled with. A ledger earns trust
2675
+ by summing exactly what its label claims.
2676
+
2677
+ A row opens where there is something to open, and the two ways are not
2678
+ interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
2679
+ its one figure — and the lookup link lives INSIDE that popover, never beside
2680
+ the trigger, because a button inside a button is invalid. `reference` is the
2681
+ flat alternative for a row with nothing to expand: one charge IS its own
2682
+ particulars, so Storage carries a trailing link instead of a door.
2683
+
2684
+ The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
2685
+ single line states the same fact twice, at two weights. That is the honest
2686
+ fixture rather than a tidy one — real groups are built from data, so some
2687
+ arrive with one row — and it puts the grammar's open question on screen: an
2688
+ unsummed group captions from ABOVE while a summed one closes from below, so
2689
+ this statement names its groups in two places. */}
2690
+ <Ledger formatValue={money}>
2691
+ <LedgerGroup label="Invoiced" total={grandTotal}>
2692
+ {invoices.map((inv) => {
2693
+ /* NO meta on this side. It briefly carried "awaiting payment method",
2694
+ and that was `meta` doing two jobs: on the Received rows it holds a
2695
+ neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
2696
+ Two unlike things in one treatment is what makes a caption read as
2697
+ inconsistent — the size was never the fault.
2698
+ The problem also had a home already: the charge row above carries
2699
+ `warning="Choose how this was paid"`, on the line with the editor that
2700
+ fixes it. A statement's job is the arithmetic, and the arithmetic
2701
+ already says it — `Outstanding` IS the unpaid money. Saying it again in
2702
+ weaker words adds a second voice for one fact. */
2703
+ /* A door where there are PARTICULARS, and the count that decides it is the
2704
+ charges, not the priced ones. An invoice whose second line is unpriced is
2705
+ exactly the row whose figure looks too small for it, which is the question
2706
+ the popover answers — gating on `amount > 0` would hide the door precisely
2707
+ when it is most useful. */
2708
+ return (
2709
+ <LedgerRow
2710
+ key={inv.key}
2711
+ label={inv.title}
2712
+ value={invoiceTotal(inv)}
2713
+ peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
2714
+ reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
2715
+ />
2716
+ );
2717
+ })}
2718
+ </LedgerGroup>
2719
+ <LedgerGroup label="Adjustments">
2720
+ <LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
2721
+ </LedgerGroup>
2722
+ {received > 0 ? (
2723
+ <LedgerGroup label="Received" total={-received}>
2724
+ {receipts.map(({ inv, charge }) => (
2725
+ <LedgerRow
2726
+ key={`${inv.key}-${charge.key}`}
2727
+ label={charge.label}
2728
+ meta={METHODS.find((m) => m.value === charge.method)?.label}
2729
+ value={-charge.amount}
2730
+ tone="success"
2731
+ />
2732
+ ))}
2733
+ </LedgerGroup>
2734
+ ) : null}
2735
+ <LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
2736
+ </Ledger>
2737
+
2594
2738
  {/* ONE action row for the section, on the control column. The deposit
2595
2739
  is a field of the record, not a band of its own — its receipt is a
2596
2740
  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.7.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,