@lotics/ui 44.9.0 → 44.10.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,33 @@ 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
+ ## 44.9.1 — a pivot's figures align, and a ledger row stops losing its own name
8
+
9
+ No API is removed and nothing fails to compile. Three renders change.
10
+
11
+ **`Matrix` right-aligns its figures instead of centring them.** Every value, column
12
+ header and total was `align="center"` while the cells also set `tabular` — and tabular
13
+ 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.
18
+
19
+ **`Matrix` row labels carry default ink, not `muted`.** A pivot whose rows are entities
20
+ rendered every entity name a step quieter than the numbers about it, inverting how the
21
+ grid is read — you find the row by its name, then scan across.
22
+
23
+ **`MATRIX_COL_GAP` is exported.** It was the literal `3` in four places, so a caller
24
+ sizing its own column budget had to hard-code it and could drift from what the grid draws.
25
+
26
+ **A `LedgerRow`'s `meta` yields before its label, and by 3×.** Only the label carried a
27
+ `flexShrink`, and RN-web resolves the unstyled caption to `1` as well — so under pressure
28
+ both shrank proportionally, and because a caption is usually the longer string, the
29
+ SUBORDINATE text ended up wider than the row's identity. Measured in a 180px row: before,
30
+ the label kept 31px of the 77 it needs while the caption kept 41 of 103; after, the label
31
+ keeps 55 and the caption 16. Any caller that dropped `meta` at small widths to dodge this
32
+ can stop.
33
+
7
34
  ## 44.8.0 — a value column gets its own floor, and eight defaults stop fighting their own docs
8
35
 
9
36
  No API is removed. Three of these change what an existing screen renders, so read them even
@@ -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
@@ -1407,8 +1407,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1407
1407
  - **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
1408
1408
  (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
1409
1409
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
1410
- the width can't fit every column it hides columns by `TableColumn.priority` (higher drops
1411
- first; default = column order, rightmost first; column 0 the identity never drops), and
1410
+ the width can't fit every column it hides columns by `TableColumn.priority` (**`1` = highest,
1411
+ as in P1/P2/P3** the smallest number is the last to go; default = column order, rightmost
1412
+ first; column 0 — the identity — never drops), and
1412
1413
  below the two-column floor every row STACKS: the identity cell (column 0) HEADS the pile
1413
1414
  unlabelled — its column name would only restate the value — and each REMAINING cell renders
1414
1415
  as a `DetailRow`-spread line (muted label left, value at the right edge — one vocabulary with
@@ -1705,7 +1706,11 @@ component rather than showing it at zero.
1705
1706
  - **`matrix`** — `Matrix`: the PIVOT cross-tab — band-compound `Matrix` root +
1706
1707
  `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the
1707
1708
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
1708
- `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter.
1709
+ `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
1710
+ RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column that is
1711
+ the reason to read a pivot at all — and row labels carry default ink, because a row's
1712
+ label is its identity, not chrome. `MATRIX_COL_GAP` is exported for a caller budgeting
1713
+ its own column widths.
1709
1714
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1710
1715
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1711
1716
  KPI can be driven off the same numbers the grid shows.
@@ -2139,7 +2144,10 @@ component rather than showing it at zero.
2139
2144
  works and, the moment it asks, renders the question IN PLACE OF the feed — in the dialog's own
2140
2145
  scroller, with the wizard's verbs pinned in the `DialogFooter` via `AgentRunActions`. Replacing
2141
2146
  rather than stacking is the CONTRACT: stacked under the transcript a multi-question ask clips
2142
- 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
2143
2151
  (`AgentRunLike`), so `useAgentRun()` satisfies it with no `@lotics/app-sdk` dependency and a
2144
2152
  mock satisfies it with no backend — `AgentRunQuestion` names the question shape so a template
2145
2153
  or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.9.0",
3
+ "version": "44.10.0",
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/ledger.tsx CHANGED
@@ -237,7 +237,7 @@ export function LedgerRow(props: LedgerRowProps) {
237
237
  {label}
238
238
  </Text>
239
239
  {meta ? (
240
- <Text size="xs" color="muted" numberOfLines={1}>
240
+ <Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
241
241
  {meta}
242
242
  </Text>
243
243
  ) : null}
@@ -357,6 +357,13 @@ const styles = StyleSheet.create({
357
357
  rowHovered: { backgroundColor: colors.zinc[50] },
358
358
  grow: { flexGrow: 1, flexShrink: 1 },
359
359
  shrink: { flexShrink: 1 },
360
+ // The META yields BEFORE the label, and by more. Only the label carried a
361
+ // shrink, so under width pressure the row'''s IDENTITY was the one thing that
362
+ // gave way while its subordinate qualifier held full width — a narrow ledger
363
+ // read "Tổng thu (chư…" beside an intact "03/06, Cash". A caption qualifies a
364
+ // number; the label names it, and the name is what a reader needs when the
365
+ // row is too tight for both.
366
+ yield: { flexShrink: 3 },
360
367
  total: { gap: 6 },
361
368
  // The BASIS row, the only one whose two sides can differ in height: its label may
362
369
  // carry a stacked `meta` while the figure stays one line. Centring then measures the
package/src/matrix.tsx CHANGED
@@ -25,6 +25,11 @@ interface MatrixContextValue {
25
25
  totalColWidth: number;
26
26
  }
27
27
 
28
+ /** Gap between a matrix's columns. Exported because a caller sizing its own
29
+ * column budget needs the same number the grid renders with — hard-coding it
30
+ * is how a width budget and the layout drift apart. */
31
+ export const MATRIX_COL_GAP = 3;
32
+
28
33
  const MatrixContext = createContext<MatrixContextValue | null>(null);
29
34
 
30
35
  function useMatrix(): MatrixContextValue {
@@ -138,12 +143,12 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
138
143
  )}
139
144
  </View>
140
145
  {cols.map((c) => (
141
- <Text key={c.key} size="xs" color="muted" align="center" numberOfLines={1} style={styles.colCell}>
146
+ <Text key={c.key} size="xs" color="muted" align="right" numberOfLines={1} style={styles.colCell}>
142
147
  {c.label}
143
148
  </Text>
144
149
  ))}
145
150
  {hasTotals ? (
146
- <Text size="xs" color="muted" weight="medium" align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
151
+ <Text size="xs" color="muted" weight="medium" align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
147
152
  {resolvedTotalLabel}
148
153
  </Text>
149
154
  ) : null}
@@ -163,14 +168,14 @@ function MatrixGrid({ display = "both" }: MatrixGridProps) {
163
168
  <View style={styles.grid}>
164
169
  {rows.map((r) => (
165
170
  <View key={r.key} style={styles.row}>
166
- <Text size="sm" color="muted" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
171
+ <Text size="sm" numberOfLines={1} style={[styles.rowLabel, { width: rowLabelWidth }]}>
167
172
  {r.label}
168
173
  </Text>
169
174
  {cols.map((c) => (
170
175
  <MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
171
176
  ))}
172
177
  {hasTotals ? (
173
- <Text size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
178
+ <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
174
179
  {formatValue(rowTotals.get(r.key) ?? 0)}
175
180
  </Text>
176
181
  ) : null}
@@ -206,7 +211,7 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
206
211
  const label = `${row.label}, ${col.label}: ${formatValue(value)}`;
207
212
 
208
213
  const content = showNumber ? (
209
- <Text size="sm" weight="medium" tabular align="center" color={value === 0 ? "zinc-500" : "default"}>
214
+ <Text size="sm" weight="medium" tabular align="right" color={value === 0 ? "zinc-500" : "default"}>
210
215
  {value === 0 ? "" : formatValue(value)}
211
216
  </Text>
212
217
  ) : null;
@@ -254,11 +259,11 @@ function MatrixTotals({ label }: MatrixTotalsProps) {
254
259
  {label ?? locale.matrix.total}
255
260
  </Text>
256
261
  {cols.map((c) => (
257
- <Text key={c.key} size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={styles.colCell}>
262
+ <Text key={c.key} size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={styles.colCell}>
258
263
  {formatValue(colTotals.get(c.key) ?? 0)}
259
264
  </Text>
260
265
  ))}
261
- <Text size="sm" weight="semibold" tabular align="center" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
266
+ <Text size="sm" weight="semibold" tabular align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
262
267
  {formatValue(grandTotal)}
263
268
  </Text>
264
269
  </View>
@@ -295,21 +300,21 @@ Matrix.Legend = MatrixLegend;
295
300
 
296
301
  const styles = StyleSheet.create({
297
302
  container: {
298
- gap: 3,
303
+ gap: MATRIX_COL_GAP,
299
304
  },
300
305
  headRow: {
301
306
  flexDirection: "row",
302
307
  alignItems: "center",
303
- gap: 3,
308
+ gap: MATRIX_COL_GAP,
304
309
  paddingBottom: 2,
305
310
  },
306
311
  grid: {
307
- gap: 3,
312
+ gap: MATRIX_COL_GAP,
308
313
  },
309
314
  row: {
310
315
  flexDirection: "row",
311
316
  alignItems: "center",
312
- gap: 3,
317
+ gap: MATRIX_COL_GAP,
313
318
  },
314
319
  rowLabel: {
315
320
  paddingRight: 8,
@@ -333,7 +338,7 @@ const styles = StyleSheet.create({
333
338
  totalsRow: {
334
339
  flexDirection: "row",
335
340
  alignItems: "center",
336
- gap: 3,
341
+ gap: MATRIX_COL_GAP,
337
342
  marginTop: 3,
338
343
  paddingTop: 8,
339
344
  borderTopWidth: 1,
package/src/table_fit.ts CHANGED
@@ -20,12 +20,22 @@ export interface TableFitColumn {
20
20
  key: string;
21
21
  /** Fixed width in px; omit for a flexible column. */
22
22
  width?: number;
23
- /** Drop precedence when the container can't fit every column: HIGHER numbers
24
- * drop first, ties drop right-to-left. The FIRST column is the row's identity
25
- * it never drops.
23
+ /** This column's claim on space, **`1` = highest**, the way P1/P2/P3 rank a
24
+ * bug: the column you least want to lose gets the SMALLEST number. Said the
25
+ * other way round it is the same rule — the larger the number, the sooner the
26
+ * column drops, and ties drop right-to-left. The FIRST column is the row's
27
+ * identity and never drops.
26
28
  *
27
- * An unannotated column defaults to `columns.length + index`, which is above
28
- * every hand-written priority rather than interleaved with them, so an
29
+ * Stated rank-first deliberately. "Higher drops first" is accurate and was
30
+ * all this said, but a reader who takes `priority` to mean "bigger is more
31
+ * important" inverts it and gives their most important column the LARGEST
32
+ * number — which is then the first column the fit sheds. That failure is
33
+ * invisible at desk width and shows only on a phone, so it reviews as
34
+ * correct. It has been read backwards once already.
35
+ *
36
+ * An unannotated column defaults to `columns.length + index`, which sits
37
+ * above the small ranks a real annotation uses rather than interleaved with
38
+ * them, so an
29
39
  * unannotated register still sheds right-to-left AND an explicit priority is
30
40
  * strictly safer than none. Defaulting to the bare index made annotation
31
41
  * actively harmful: marking your most important column `priority: 1` TIED it