@lotics/ui 44.9.1 → 44.10.1

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,79 @@ 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
+ **`FileRow`'s `leading` slot is a SIBLING of the press door, not inside it.** No API change;
8
+ a row with `onPress` now has a slightly smaller press target, excluding the leading mark.
9
+
10
+ `trailing`'s own docstring already stated the rule — *"an independently-interactive SIBLING:
11
+ never swallowed by the row press (a button never nests in the door button)"* — while `leading`
12
+ recommended *"a selection checkbox"* and rendered inside the `Pressable`. So the documented
13
+ usage produced an interactive control nested in a `button`: invalid ARIA, and one press with two
14
+ owners, since ticking the box also opened the file. A slot the docs tell you to put a control in
15
+ cannot be inside the control.
16
+
17
+ ## 44.9.3 — an option's description stops being erased, and a pivot's headers rejoin the ramp
18
+
19
+ **`OptionList` no longer suppresses `getOptionDescription` when `renderOptionContent` is set.**
20
+ The two are independent facts about an option — how its value renders, and a subtitle under it —
21
+ and one was erasing the other. A caller who wanted both had one slot for two things, so they put
22
+ the subtitle inside the custom render; `Select` and `InlineSelect` then reuse that render as the
23
+ RESTING trigger display, which turned a gloss meant for the open menu into a two-line control on
24
+ every read (measured at 55px against 40px for every other editor in the same table). Pass no
25
+ `getOptionDescription` if you want no subtitle. The documented `renderSelected` →
26
+ `renderOptionContent` → `label` fallback is unchanged; it was never the defect.
27
+
28
+ **`Matrix`'s column headers are `sm` / `muted` / `medium`, matching `SortHeader`.** They were
29
+ `xs` / regular — which composition.md already names as the failure: *"a column header is a LABEL
30
+ at the body size… dropped to `xs` and lightened past `muted` it stops being legible and starts
31
+ reading as a caption that lost its value."* A screen carrying both a `Table` and a `Matrix`
32
+ rendered the same structural role two ways.
33
+
34
+ ## 44.9.3 — the rules probe could not see a rule
35
+
36
+ **`docs/reviewing.md`'s probe 3 collector missed border-drawn rules and counted transparent
37
+ spacers.** Docs only, no code change — but this is the method app authors run, so it shipped a
38
+ blind spot: it selected on "thin and wide" with no paint check, which counts a zero-height flex
39
+ spacer as a rule and misses a band drawn as a `borderBottom` on a full-height header row. A
40
+ surface with a visible rule under its column headers therefore reported **zero**, and zero reads
41
+ as a clean result rather than a blind probe.
42
+
43
+ The snippet now requires actual paint, accepts a single-sided border on a box of any height,
44
+ excludes four-sided boxes (a control's own edge is not a rule), reads the line's y from the border
45
+ edge rather than the box origin, and reports what each rule SEPARATES — which is what probe 3
46
+ actually asks.
47
+
48
+ ## 44.9.2 — a pivot's figures actually align, and a ledger row keeps its own name
49
+
50
+ **`Matrix.Grid`'s cell stretches its figure instead of centring it, so 44.9.1's right-alignment
51
+ actually applies.** A `Text` centred by its parent shrink-wraps, so the `align="right"` added in
52
+ 44.9.1 had nothing to align against and the value cells never moved — while the headers and totals,
53
+ which are not centred, did. Measured on a real pivot as shipped in 44.9.1, figures in one column
54
+ ended across a **47px** spread; with the cell stretched, **0**. `Matrix.Header`'s column cell also
55
+ gains a 2px right padding, matching the inset the cell's own border imposes, so a header and the
56
+ numbers beneath it end on one x rather than two pixels apart.
57
+
58
+ **`Ledger` measures itself and `LedgerRow` stops rendering `meta` in a row too tight for it.**
59
+ No API change; a narrow ledger simply shows fewer captions.
60
+
61
+ 44.9.1 made the caption yield to the label first, which was the right ORDER and not a floor:
62
+ shrinking indefinitely still starves the identity. Measured at 375, a 30-character caption
63
+ wanted 184px of a ~250px text budget and clipped anyway, ellipsising a 74px label at 71px. The
64
+ row now drops the caption entirely below the width where a label, a caption and a figure can
65
+ all be seated (`FLEX_MIN_WIDTH` for the identity, ~100 for the money column, ~80 before a
66
+ caption stops being worth rendering).
67
+
68
+ Measured across the three states in a 180px row, the label kept 31px, then 55px, then 74px of
69
+ the 77 it needs.
70
+
71
+ `Ledger` measures its own box via `onLayout`, the way `Table` / `DetailTable` / `Breakdown`
72
+ already do — not the window, and once for the whole ledger rather than per row. A ledger in a
73
+ narrow column on a wide screen is exactly the case a window-sized answer gets wrong. Two
74
+ separate app authors had already worked around this by dropping `meta` at small widths in their
75
+ own code; that is the component's job.
76
+
77
+ `LedgerBasis` is unaffected — it stacks its caption UNDER the label, so the two never compete
78
+ for one line.
79
+
7
80
  ## 44.9.1 — a pivot's figures align, and a ledger row stops losing its own name
8
81
 
9
82
  No API is removed and nothing fails to compile. Three renders change.
@@ -11,10 +84,16 @@ No API is removed and nothing fails to compile. Three renders change.
11
84
  **`Matrix` right-aligns its figures instead of centring them.** Every value, column
12
85
  header and total was `align="center"` while the cells also set `tabular` — and tabular
13
86
  figures exist so digits line up in a column, which centring throws away. Measured on a
14
- real pivot: centred, 24 figures painted across **14 distinct right edges**; right-aligned,
15
- **12** exactly one per column. No `align` prop came with this: a matrix is one measure
16
- across two dimensions, so its cells cannot want different alignments, and a prop for a
17
- centred case nobody has is surface without a consumer.
87
+ **This was only half the fix see 44.9.2.** `Matrix.Grid`'s cell sets `alignItems: "center"`,
88
+ which makes the figure inside SHRINK-WRAP, and `align="right"` on a box exactly as wide as its
89
+ own glyphs does nothing. So this release right-aligned the headers and totals (which carry no
90
+ such centring) and left the value cells centred worse than either end state, since a figure
91
+ could then sit up to 45px left of its own column header's right edge. The measurement quoted
92
+ below counted distinct right edges across ALL figures rather than the spread WITHIN a column,
93
+ which is why it read as an improvement. Measured on a real pivot: centred, 24 figures painted
94
+ across 14 distinct right edges; right-aligned, 12. No `align` prop came with this: a matrix is
95
+ one measure across two dimensions, so its cells cannot want different alignments, and a prop for
96
+ a centred case nobody has is surface without a consumer.
18
97
 
19
98
  **`Matrix` row labels carry default ink, not `muted`.** A pivot whose rows are entities
20
99
  rendered every entity name a step quieter than the numbers about it, inverting how the
@@ -464,12 +464,21 @@ Reach for it whenever an agent run happens inside a dialog — which is nearly a
464
464
  const run = useAgentRun("intake"); // @lotics/app-sdk
465
465
  <AgentRunScope> {/* wraps the Dialog, like ClarifyWizardScope */}
466
466
  <Dialog …>
467
- <AgentRunPane run={run} onCancel={back} /> {/* content */}
467
+ <AgentRunPane run={run} onCancel={back} onLanding={landed} /> {/* content */}
468
468
  <DialogFooter><AgentRunActions run={run} onStop={back} /></DialogFooter> {/* actions */}
469
469
  </Dialog>
470
470
  </AgentRunScope>
471
471
  ```
472
472
 
473
+ **Pass `onLanding` if the run's RESULT drives anything.** The pane owns the `answerChoice` call, so
474
+ after a question it is the only thing holding how the answered leg ended — your own `run()` promise
475
+ already resolved, with `parked`, and never resolves again. Give `onLanding` the same handler you
476
+ give `run()`: it is typed from your run (no cast), and it re-fires if answering leads to a follow-up
477
+ ask. Omit it and an answered run completes with the host none the wiser — the transcript reads
478
+ "done", your review step never opens, and the paid read is discarded. A host that instead reads its
479
+ result off `status`/`output` does not need it, but is then re-deriving from a render what it was
480
+ handed directly.
481
+
473
482
  **The parked question REPLACES the feed — that is the contract, not a style.** The run is blocked
474
483
  on the answer, so the question is the only thing to act on; it gets the dialog's own scroller and
475
484
  its actions sit outside it. Stacked under the transcript in an unscrollable box — the arrangement
package/docs/catalog.md CHANGED
@@ -2144,7 +2144,10 @@ component rather than showing it at zero.
2144
2144
  works and, the moment it asks, renders the question IN PLACE OF the feed — in the dialog's own
2145
2145
  scroller, with the wizard's verbs pinned in the `DialogFooter` via `AgentRunActions`. Replacing
2146
2146
  rather than stacking is the CONTRACT: stacked under the transcript a multi-question ask clips
2147
- its own Submit and strands a live `awaiting_input` run. `run` is a structural shape
2147
+ its own Submit and strands a live `awaiting_input` run. `onLanding` hands back how the ANSWERED
2148
+ leg ended — the pane makes that call, so without it the continuation's outcome is lost and a host
2149
+ waiting on its own `run()` promise waits forever (that promise already resolved `parked`). A
2150
+ refused answer keeps the question up and shows why, rather than a dead Submit. `run` is a structural shape
2148
2151
  (`AgentRunLike`), so `useAgentRun()` satisfies it with no `@lotics/app-sdk` dependency and a
2149
2152
  mock satisfies it with no backend — `AgentRunQuestion` names the question shape so a template
2150
2153
  or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
package/docs/reviewing.md CHANGED
@@ -187,7 +187,9 @@ rule behind it lives in the area doc named beside it; this file never restates o
187
187
  → [composition.md](./composition.md) §"Color discipline", §"Buttons & action labels", §"Every number is a door", §"Hover grammar".
188
188
 
189
189
  ### 3. Rules — ask what each one SEPARATES
190
- **Collect** every border/divider with weight, colour and width.
190
+ **Collect** every border/divider with weight, colour and width — including the ones drawn as a
191
+ border on a box of ordinary height, which is how a column band is usually painted. A collector that
192
+ matches only thin boxes reports zero rules on a surface that has one, and zero reads as clean.
191
193
 
192
194
  The test is not how big the break is. A rule between two rows divides **like from like** —
193
195
  whitespace says that just as well, which is why a register can drop every internal rule and read
@@ -441,25 +443,59 @@ that has none, or a clean tally on a screen with an untracked component still on
441
443
 
442
444
  ### Separator beats — is the biggest boundary the widest?
443
445
 
446
+ **A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone gets both
447
+ halves wrong, and both wrongs are silent: a transparent zero-height flex spacer matches it and is
448
+ counted, while a border on a box of ordinary height does not and is missed. The second is the one
449
+ that bites — a component that draws its column band as a `borderBottom` on a full-height header row
450
+ then reports **zero rules on a surface that visibly has one**, and a count of `0` reads as a clean
451
+ result rather than a blind probe.
452
+
444
453
  ```js
445
454
  () => {
446
- const rules = [...new Set([...document.querySelectorAll('div')]
447
- .filter(e => { const r = e.getBoundingClientRect(); return r.height <= 1.5 && r.width > 200; })
448
- .map(e => Math.round(e.getBoundingClientRect().y)))].sort((a, b) => a - b);
455
+ const CLEAR = v => !v || v === 'rgba(0, 0, 0, 0)' || v === 'transparent';
456
+ const lineOf = e => {
457
+ const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
458
+ if (!(r.width > 200 && e.checkVisibility?.())) return null;
459
+ if (r.height <= 1.5) { // a hairline BOX
460
+ if (!CLEAR(cs.backgroundColor)) return { y: r.y, paint: cs.backgroundColor };
461
+ for (const s of ['Top', 'Bottom']) // …or a hairline drawn as a border
462
+ if (parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]))
463
+ return { y: r.y, paint: cs[`border${s}Color`] };
464
+ return null; // painted nothing: a spacer, not a rule
465
+ }
466
+ const sides = ['Top', 'Bottom'].filter(s =>
467
+ parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]));
468
+ const boxed = ['Left', 'Right'].every(s =>
469
+ parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`])) && sides.length === 2;
470
+ if (boxed || sides.length !== 1) return null; // a control's own edge is not a rule
471
+ // The LINE's y is the border's edge, never the box's origin.
472
+ return { y: sides[0] === 'Top' ? r.y : r.bottom, paint: cs[`border${sides[0]}Color`] };
473
+ };
474
+ const rules = [...document.querySelectorAll('div,hr')]
475
+ .map(lineOf).filter(Boolean)
476
+ .map(l => ({ y: Math.round(l.y), paint: l.paint }))
477
+ .sort((a, b) => a.y - b.y);
449
478
  const blocks = [...document.querySelectorAll('*')]
450
479
  .filter(e => e.children.length === 0 && e.textContent.trim())
451
- .map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom) }; });
452
- return rules.map(y => ({
453
- rule: y,
454
- above: y - Math.max(...blocks.filter(o => o.b <= y).map(o => o.b), -Infinity),
455
- below: Math.min(...blocks.filter(o => o.y >= y).map(o => o.y), Infinity) - y,
480
+ .map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom), t: e.textContent.trim().slice(0, 24) }; });
481
+ return rules.map(({ y, paint }) => ({
482
+ rule: y, paint,
483
+ above: y - Math.max(...blocks.filter(o => o.b <= y + 1).map(o => o.b), -Infinity),
484
+ below: Math.min(...blocks.filter(o => o.y >= y - 1).map(o => o.y), Infinity) - y,
485
+ separates: [blocks.filter(o => o.b <= y + 1).sort((a, c) => c.b - a.b)[0]?.t,
486
+ blocks.filter(o => o.y >= y - 1).sort((a, c) => a.y - c.y)[0]?.t].join(' | '),
456
487
  }));
457
488
  }
458
489
  ```
459
490
 
491
+ Read `separates` first — probe 3 asks what each rule DIVIDES, and a rule whose two neighbours are
492
+ the same kind of thing is the one to delete however important the break feels.
493
+
460
494
  Unequal `above`/`below` on one rule, or a rule with a visibly tighter beat than its neighbours, is
461
- the finding. A hand-rolled `Divider` sitting in a parent's uniform `gap` next to a `SectionStack`
462
- is the usual cause.
495
+ the other finding. A hand-rolled `Divider` sitting in a parent's uniform `gap` next to a
496
+ `SectionStack` is the usual cause. Asymmetry is not automatically wrong: a rule that BELONGS to
497
+ the element above it sits tight under it and loose before the next, and that gap is what says
498
+ which side owns it — the finding is asymmetry nobody chose.
463
499
 
464
500
  ### Clipping, overflow, collision
465
501
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.9.1",
3
+ "version": "44.10.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -1,7 +1,8 @@
1
- import { useCallback, useMemo, type ReactNode } from "react";
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { AgentRun, type AgentRunProps } from "./agent_run";
4
4
  import { Button } from "./button";
5
+ import { Text } from "./text";
5
6
  import { useLoticsLocale } from "./locale";
6
7
  import type { AgentUIPart } from "./agent_transform";
7
8
  import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
@@ -31,12 +32,20 @@ export interface AgentRunQuestion {
31
32
  allow_custom?: boolean;
32
33
  }
33
34
 
34
- export interface AgentRunLike {
35
+ export interface AgentRunLike<TLanding = unknown> {
35
36
  status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
36
37
  parts: readonly AgentUIPart[];
37
38
  /** Non-null exactly while parked. */
38
39
  pendingChoice: { questions: AgentRunQuestion[] } | null;
39
- answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
40
+ /**
41
+ * GENERIC in what it resolves, and that is the whole point of the parameter.
42
+ * A run leg reports HOW it ended — settled with an output, parked again on a
43
+ * follow-up, failed, aborted — and this pane is the thing that calls it, so it
44
+ * is the only thing holding that answer. Naming the type would mean importing
45
+ * `@lotics/app-sdk` and breaking the one-way boundary above; inferring it from
46
+ * the caller's own run costs nothing and hands `onLanding` back fully typed.
47
+ */
48
+ answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<TLanding>;
40
49
  /**
41
50
  * STOP the run — server-side, not just locally. `useAgentRun().cancel` is
42
51
  * exactly this; do NOT pass its `abort`, which detaches the listener and
@@ -61,12 +70,30 @@ export function AgentRunScope({ children }: { children: ReactNode }) {
61
70
  return <ClarifyWizardScope>{children}</ClarifyWizardScope>;
62
71
  }
63
72
 
64
- export interface AgentRunPaneProps {
65
- run: AgentRunLike;
73
+ export interface AgentRunPaneProps<TLanding = unknown> {
74
+ run: AgentRunLike<TLanding>;
66
75
  labelForCall?: AgentRunProps["labelForCall"];
67
76
  renderToolOutput?: AgentRunProps["renderToolOutput"];
68
77
  /** Abandon a parked question — the run is left for the operator to retry. */
69
78
  onCancel: () => void;
79
+ /**
80
+ * How the ANSWERED leg ended. Required for correctness in any host that acts
81
+ * on a run's result, because this pane owns the `answerChoice` call and the
82
+ * caller's own `run()` promise already resolved — with `parked` — and will
83
+ * never resolve again.
84
+ *
85
+ * Without it the continuation's outcome had nowhere to go: the pane discarded
86
+ * it, and a host that had been told a landing is how you learn an outcome sat
87
+ * on a finished transcript with nothing to advance to. That shipped, and cost
88
+ * a paid extraction every time an agent asked a question.
89
+ *
90
+ * Re-fires for a follow-up ask: answering can land `parked` again.
91
+ *
92
+ * A host reading its result off hook state instead does not need this — but it
93
+ * is then re-deriving from a render what it was handed directly, which is the
94
+ * pattern the landing union exists to retire.
95
+ */
96
+ onLanding?: (landing: TLanding) => void;
70
97
  }
71
98
 
72
99
  /**
@@ -80,8 +107,9 @@ export interface AgentRunPaneProps {
80
107
  * dialog's height and clipped its own Submit. Every app rebuilt this arrangement
81
108
  * by hand and it only had to be got wrong once.
82
109
  */
83
- export function AgentRunPane(props: AgentRunPaneProps) {
84
- const { run, labelForCall, renderToolOutput, onCancel } = props;
110
+ export function AgentRunPane<TLanding>(props: AgentRunPaneProps<TLanding>) {
111
+ const { run, labelForCall, renderToolOutput, onCancel, onLanding } = props;
112
+ const loc = useLoticsLocale().agentRun;
85
113
  // The DIALOG's gutter, not this pane's own container measurement. Asking
86
114
  // `useContainerSize()` here answered "small" for a 760px dialog while the
87
115
  // header, scroll area and footer asked the SCREEN and answered "not small" —
@@ -101,17 +129,35 @@ export function AgentRunPane(props: AgentRunPaneProps) {
101
129
  })),
102
130
  [pending],
103
131
  );
132
+ // A REFUSED answer (400 invalid, 409 raced cancel) leaves the run parked and
133
+ // still answerable, so the wizard stays up and the reason goes above it. It
134
+ // used to be a bare `void`, which made that an unhandled rejection: the button
135
+ // did nothing, the question stayed, and nothing said why.
136
+ const [answerError, setAnswerError] = useState<string | null>(null);
104
137
  const submit = useCallback(
105
138
  (answers: ClarifyWizardAnswer[]) => {
106
- void run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom })));
139
+ setAnswerError(null);
140
+ run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom }))).then(
141
+ (landing) => onLanding?.(landing),
142
+ // A rejection is always an `Error` on the SDK's path, but a promise can
143
+ // carry anything, so the fallback is the localized label rather than
144
+ // whatever `String()` makes of a non-Error — "[object Object]" is not a
145
+ // thing to show an operator who is mid-answer.
146
+ (e: unknown) => setAnswerError(e instanceof Error ? e.message : loc.error),
147
+ );
107
148
  },
108
- [run],
149
+ [run, onLanding, loc],
109
150
  );
110
151
 
111
152
  if (pending) {
112
153
  return (
113
154
  <DialogScrollArea>
114
- <View style={{ paddingVertical: 4 }}>
155
+ <View style={{ paddingVertical: 4, gap: 8 }}>
156
+ {answerError !== null ? (
157
+ <Text size="sm" color="danger">
158
+ {answerError}
159
+ </Text>
160
+ ) : null}
115
161
  <ClarifyWizard questions={questions} onSubmit={submit} onCancel={onCancel} />
116
162
  </View>
117
163
  </DialogScrollArea>
package/src/file_row.tsx CHANGED
@@ -40,6 +40,14 @@ export interface FileRowProps {
40
40
  * Leading slot, BEFORE the badge — a `DiffMark` saying what happened to this
41
41
  * document, a selection checkbox, a status dot.
42
42
  *
43
+ * A SIBLING of the row's press door, like `trailing` — not inside it. It used
44
+ * to render within the `Pressable`, which made the documented checkbox an
45
+ * interactive control nested in a `button`: invalid ARIA, and two owners for
46
+ * one press, since ticking the box also opened the file. The cost of the fix
47
+ * is that the mark itself is no longer part of the door; that is the correct
48
+ * trade, because a slot the docs tell you to put a control in cannot be
49
+ * inside the control.
50
+ *
43
51
  * It sits outside the badge rather than on it because it must line up DOWN
44
52
  * the list: a mark drawn over a 30px badge moves with the badge's size, and
45
53
  * `sm` and `md` rows would stop agreeing. Give every row the same slot,
@@ -91,7 +99,6 @@ export function FileRow({
91
99
 
92
100
  const content = (
93
101
  <>
94
- {leading}
95
102
  <FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
96
103
  <View style={styles.text}>
97
104
  <Text size="sm" weight="medium" numberOfLines={1}>
@@ -114,6 +121,7 @@ export function FileRow({
114
121
  if (!onPress) {
115
122
  return (
116
123
  <View style={[styles.row, md && styles.rowMd]}>
124
+ {leading}
117
125
  {content}
118
126
  {trailing}
119
127
  </View>
@@ -130,6 +138,7 @@ export function FileRow({
130
138
  ]}
131
139
  {...mouseProps}
132
140
  >
141
+ {leading}
133
142
  <Pressable
134
143
  onPress={onPress}
135
144
  {...focusProps}
package/src/ledger.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, type ReactNode } from "react";
1
+ import { createContext, useContext, useState, type ReactNode } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Divider } from "./divider";
@@ -31,8 +31,17 @@ import { Text } from "./text";
31
31
  // <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
32
32
  // </Ledger>
33
33
 
34
+ /** Below this the row cannot seat a label, a caption and a figure at once.
35
+ * Derived, not chosen: the identity needs a flex column's usable floor
36
+ * (`FLEX_MIN_WIDTH`, 120), a money column runs ~100, and a caption is not worth
37
+ * rendering under ~80 — plus the row's own gaps. Under that the caption is
38
+ * taking room from the one string the reader needs. */
39
+ const META_MIN_ROW_WIDTH = 320;
40
+
34
41
  interface LedgerContextValue {
35
42
  format: (n: number) => string;
43
+ /** The row is too tight to carry a caption as well — see `META_MIN_ROW_WIDTH`. */
44
+ dropMeta: boolean;
36
45
  }
37
46
 
38
47
  const LedgerContext = createContext<LedgerContextValue | null>(null);
@@ -61,9 +70,25 @@ export interface LedgerProps {
61
70
 
62
71
  export function Ledger(props: LedgerProps) {
63
72
  const { formatValue, children, style } = props;
73
+ // MEASURES ITSELF, like `Table`/`DetailTable`/`Breakdown` — not the window,
74
+ // and not per row. A ledger in a narrow column on a wide screen is the case a
75
+ // window-sized answer gets wrong, and one `onLayout` on the container serves
76
+ // every row in it.
77
+ //
78
+ // 0 until first layout, which reads as "not yet too narrow" — a caption that
79
+ // appears on the second frame is a far smaller wrong than one that flashes
80
+ // away, and a ledger wide enough to matter answers on that frame anyway.
81
+ const [width, setWidth] = useState(0);
64
82
  return (
65
- <LedgerContext.Provider value={{ format: formatValue }}>
66
- <View style={[styles.ledger, style]}>{children}</View>
83
+ <LedgerContext.Provider
84
+ value={{ format: formatValue, dropMeta: width > 0 && width < META_MIN_ROW_WIDTH }}
85
+ >
86
+ <View
87
+ style={[styles.ledger, style]}
88
+ onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
89
+ >
90
+ {children}
91
+ </View>
67
92
  </LedgerContext.Provider>
68
93
  );
69
94
  }
@@ -230,13 +255,20 @@ export interface LedgerRowProps {
230
255
  export function LedgerRow(props: LedgerRowProps) {
231
256
  const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
232
257
  const rowDetails = useLoticsLocale().ledger.rowDetails;
233
- const { format } = useLedger();
258
+ const { format, dropMeta } = useLedger();
259
+ // The caption goes entirely rather than shrinking to nothing. Yielding first
260
+ // (below) is the right ORDER but not a floor: measured at 375, a 30-character
261
+ // caption still wanted 184px of a ~250px text budget and clipped — and took
262
+ // the label down with it, ellipsising a 74px identity at 71px. Two separate
263
+ // authors had already worked around this by dropping `meta` at small widths
264
+ // in their own apps, which is the component's job.
265
+ const showMeta = meta != null && meta !== "" && !dropMeta;
234
266
  const content = (
235
267
  <>
236
268
  <Text size="sm" numberOfLines={1} style={styles.shrink}>
237
269
  {label}
238
270
  </Text>
239
- {meta ? (
271
+ {showMeta ? (
240
272
  <Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
241
273
  {meta}
242
274
  </Text>
package/src/matrix.tsx CHANGED
@@ -143,12 +143,12 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
143
143
  )}
144
144
  </View>
145
145
  {cols.map((c) => (
146
- <Text key={c.key} size="xs" color="muted" align="right" numberOfLines={1} style={styles.colCell}>
146
+ <Text key={c.key} size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={styles.colCell}>
147
147
  {c.label}
148
148
  </Text>
149
149
  ))}
150
150
  {hasTotals ? (
151
- <Text size="xs" color="muted" weight="medium" align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
151
+ <Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
152
152
  {resolvedTotalLabel}
153
153
  </Text>
154
154
  ) : null}
@@ -325,12 +325,23 @@ const styles = StyleSheet.create({
325
325
  height: 36,
326
326
  borderRadius: 4,
327
327
  borderWidth: 2,
328
- alignItems: "center",
328
+ // STRETCH, not center. The figure inside sets `align="right"`, and a Text
329
+ // centred by its parent SHRINK-WRAPS — so it is exactly as wide as its own
330
+ // glyphs and has nothing to align against. Right-aligning the text while
331
+ // this said `center` therefore changed the headers and totals (which carry
332
+ // no such centring) and left the cells where they were, so a figure sat up
333
+ // to 45px left of its own column header's right edge. `justifyContent`
334
+ // still centres vertically: in a column-direction box that is the main
335
+ // axis, and only `alignItems` decides the horizontal.
336
+ alignItems: "stretch",
329
337
  justifyContent: "center",
330
338
  },
331
339
  colCell: {
332
340
  flex: 1,
333
341
  minWidth: 52,
342
+ // Matches the 2px the cell's own border insets its figure by, so a header
343
+ // and the numbers under it end on ONE x rather than two pixels apart.
344
+ paddingRight: 2,
334
345
  },
335
346
  totalCol: {
336
347
  paddingLeft: 4,
@@ -157,7 +157,21 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
157
157
  // description — is `MenuListItem`: the listbox row that GROWS. `MenuButton` is
158
158
  // the fixed-height single-line menu row, and stays that for plain options.
159
159
  return content || desc ? (
160
- <MenuListItem key={`${row.kind}-${opt.value}`} {...shared} title={content ?? label} description={content ? undefined : desc} />
160
+ <MenuListItem
161
+ key={`${row.kind}-${opt.value}`}
162
+ {...shared}
163
+ title={content ?? label}
164
+ // A custom render and a description are INDEPENDENT facts about
165
+ // an option — how its value looks, and a subtitle under it — so
166
+ // one does not erase the other. Suppressing `desc` whenever
167
+ // `renderOptionContent` was set left a caller who wanted both
168
+ // with one slot for two things: they crammed the subtitle into
169
+ // the custom render, which `Select`/`InlineSelect` then reuse
170
+ // as the RESTING trigger display, so a gloss meant for the menu
171
+ // became a two-line control on every read. A caller who wants no
172
+ // subtitle passes no `getOptionDescription`.
173
+ description={desc}
174
+ />
161
175
  ) : (
162
176
  <MenuButton
163
177
  key={`${row.kind}-${opt.value}`}