@lotics/ui 43.7.1 → 44.1.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,44 @@ 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.0.0 — `Finding` is horizontal, and `FindingComparison` is gone
8
+
9
+ `Finding` stacked six blocks — badge, title, detail, a labelled row per side, a hairline, the
10
+ delta, then `Sources` chips. It spent roughly 200px to say one sentence and one figure, buried
11
+ the delta in the middle where nothing scans it, and named every source twice: once in the
12
+ comparison rows, again in the chips below them.
13
+
14
+ It is now two lines: a severity dot, the title, and the delta right-aligned on that line, over
15
+ one dim line of readings. **`FindingComparison` is removed** — its job is the new `readings`
16
+ prop, and the readings line carries the provenance, so `sources`/`onOpenSource` on `Finding` are
17
+ replaced by `onOpenSource(source: string)` against a reading's own name.
18
+
19
+ ```tsx
20
+ // before
21
+ <Finding severity="critical" title="Quantity disagrees" detail="…"
22
+ sources={[{ id: "inv", label: "invoice.pdf", kind: "document" }]}>
23
+ <FindingComparison
24
+ values={[{ label: "invoice.pdf", value: "480 pcs" },
25
+ { label: "packing-list.pdf", value: "440 pcs" }]}
26
+ delta="−40 pcs" />
27
+ </Finding>
28
+
29
+ // after
30
+ <Finding
31
+ severity="critical"
32
+ title="Quantity disagrees"
33
+ detail="…"
34
+ readings={[{ source: "invoice.pdf", value: "480 pcs" },
35
+ { source: "packing-list.pdf", value: "440 pcs" }]}
36
+ delta="−40 pcs"
37
+ />
38
+ ```
39
+
40
+ `metric`/`metricCaption` are gone with it: a one-number finding is a `delta` with no readings.
41
+ The `children` slot is unchanged, so a `Table`, `ProgressBar` or `Confidence` body still
42
+ composes. Readings render at `sm` rather than `xs` — they are the values the finding rests on,
43
+ and hierarchy comes from weight and colour, never from shrinking the evidence.
44
+
7
45
  ## 43.5.0 — registers, type and theming all change appearance
8
46
 
9
47
  No API is removed and nothing throws, but four DEFAULTS moved, so every register and every avatar
@@ -550,17 +550,24 @@ fail → low) — never render a fabricated score.
550
550
  ## Findings — evidence, not writes
551
551
 
552
552
  `Finding` (`@lotics/ui/finding`) is one ranked insight from an AI check — a cross-check
553
- discrepancy, an audit observation, a briefing item: severity dot-badge + localized word,
554
- `title`, `detail`, the PROMINENT `metric` (lg semibold, never a side note) + `metricCaption`,
555
- `Sources` chips at the bottom (`sources`/`onOpenSource`), with a `children` slot between body and
556
- sources. Severity colour: `critical` red, `warning` amber, `info` zinc ("Note"), `positive`
557
- emerald ("On track"); words come from the `finding` locale slice. **Display-only**: a finding
558
- informs the action the human takes in the app; it decides nothing itself — no phantom "record
559
- verdict" write (a persisted check-status goes stale on the next edit). Stack several most-severe
560
- first render each as its own section separated by a `Divider` (a finding that only informs
561
- apply). `FindingComparison` is the expected-vs-actual body: each disagreeing side a labeled row
562
- (source, value), a hairline, then the DELTA emphasized compose it as the finding's children
563
- for any one-value-disagrees insight (quantities, totals, dates).
553
+ discrepancy, an audit observation, a briefing item laid out HORIZONTALLY, in the order a reader
554
+ needs it: a severity dot, the `title`, and the `delta` right-aligned on that same line, then one
555
+ dim line of `readings` (`source value · source value`) beneath. Two lines carry all four things a
556
+ reader wants, and scanning a stack gives severity down the left edge and magnitude down the
557
+ right. `detail` is the CONSEQUENCE and is optional a second sentence restating the title in
558
+ longer words costs a line and adds nothing.
559
+
560
+ BOTH readings stay on screen and NEITHER is struck through or marked wrong: a finding reports
561
+ that two sources disagree, and which one is right is the reader's call. Omit `delta` where the
562
+ fields are not commensurable two spellings of a name have no difference to compute. The
563
+ readings ARE the provenance, so a `Sources` chip row underneath would name every document a
564
+ second time; chips belong on surfaces that cite a source without quoting it.
565
+
566
+ Severity → colour: `critical` red, `warning` amber, `info` zinc ("Note"), `positive` emerald
567
+ ("On track"); words come from the `finding` locale slice and reach assistive tech as the dot's
568
+ label, since colour alone carries nothing. **Display-only**: a finding informs the action the
569
+ human takes in the app; it decides nothing itself — no phantom "record verdict" write (a
570
+ persisted check-status goes stale on the next edit). Stack several most-severe first.
564
571
 
565
572
  ## Session, not chat
566
573
 
package/docs/catalog.md CHANGED
@@ -350,11 +350,13 @@ is a `DiffValue` over `CardSelectItem` candidates — and the ROW around any of
350
350
  your screen. See the AI-patterns doc indexed in [AGENTS.md](../AGENTS.md) for the laws; `Clarify` (the agent asks back — selectable
351
351
  `ChoiceList` options), `Sources` (provenance chips for AI output — at review scale,
352
352
  `label={null}` slots the chips at a section's bottom), `Finding` (one ranked insight from an
353
- AI check localized severity word, title, detail, `Sources` chips, a `children` slot;
354
- **`FindingComparison`** is the expected-vs-actual body: each disagreeing side a labeled row,
355
- the DELTA emphasized under a hairline (localized "Difference") quantities, totals, dates;
356
- a plain `metric` prop remains for one-number findings. The children slot composes ANY visual
357
- result a compact `Table` for per-line detail (danger color on the offending cells),
353
+ AI check, laid out HORIZONTALLY: a severity dot, the title, and the `delta` right-aligned on
354
+ that same line, over one dim line of `readings` `source value · source value`, both kept on
355
+ screen with NEITHER marked as the wrong one, since a finding reports that two sources disagree
356
+ and which is right is the reader's call. `detail` is the optional consequence. A reader scanning
357
+ a stack gets severity down the left edge and magnitude down the right. The readings ARE the
358
+ provenance, so there are no `Sources` chips under it repeating the same names. The children slot
359
+ composes ANY visual result — a compact `Table` for per-line detail (danger color on the offending cells),
358
360
  `ProgressBar` for consumption-toward-a-cliff (free time, credit), dot `Badge`s for a
359
361
  present/missing checklist, `Confidence` for judgment calls. Display-only — it informs the
360
362
  verdict the host records; `finding` locale slice). `ApprovalPrompt` (the surface that ANSWERS
@@ -1915,7 +1917,7 @@ component rather than showing it at zero.
1915
1917
  **`before === after` collapses to ONE plain value** —
1916
1918
  agreement is not a change, and a document confirming what the record already says is the
1917
1919
  GOOD case, not a diff of a value against itself. **`delta`** (a host-formatted string, the
1918
- same contract as `FindingComparison.delta`) prints HOW FAR it moved, because
1920
+ same contract as `Finding.delta`) prints HOW FAR it moved, because
1919
1921
  `1.600.000 → 1.481.481` otherwise makes the reader subtract to find out whether a
1920
1922
  correction is trivial or alarming. `layout` stacked (a column of figures) | inline (prose, dense
1921
1923
  rows); `align`/`tabular` for money; `tone` for whether the change is good news. Omit `before`
@@ -1990,9 +1992,10 @@ component rather than showing it at zero.
1990
1992
  text IS the value (a value not in `options` reads as the custom answer).
1991
1993
  - **`sources`** — `Sources` + `SourceRef`/`SourceKind` (record | document | table | web |
1992
1994
  knowledge): provenance chips, per-kind glyphs.
1993
- - **`finding`** — `Finding` + `FindingComparison` + `FindingSeverity`/`FindingLabels`: one
1994
- ranked AI-check insight — severity word, title, detail, `Sources`, children slot; the
1995
- expected-vs-actual body with the emphasized delta; the `finding` locale slice.
1995
+ - **`finding`** — `Finding` + `FindingReading` + `FindingSeverity`/`FindingLabels`: one ranked
1996
+ AI-check insight on two lines — severity dot, title, right-aligned `delta`, then the
1997
+ `readings` with the source each came from; optional `detail` consequence, children slot, and
1998
+ the `finding` locale slice.
1996
1999
  - **`result_header`** — `ResultHeader` (+ `ResultTone`): the save-direct RECEIPT's outcome
1997
2000
  strip, on the page grid — tone mark (`ok`/`attention`/`error`/`skipped`) inline on the
1998
2001
  title row, outcome-first title, right-slot `action` (the open-record jump / a retry),
@@ -2033,6 +2036,16 @@ component rather than showing it at zero.
2033
2036
  `Popover` otherwise. A popover's own anchored-vs-sheet presentation is NOT one of them —
2034
2037
  `PopoverContent` reads this itself and takes no prop. Everything spatial wants
2035
2038
  `useContainerSize`.
2039
+ - **`use_debounced_callback`** — `useDebouncedCallback(fn, ms)`: run `fn` only once its caller
2040
+ has stopped calling, returning the debounced function plus `.cancel()`. The latest `fn` always
2041
+ wins (held by ref, so a new closure each render never resets the timer) and a pending call is
2042
+ dropped on unmount. **Reach for it whenever typing drives a request** — a search box wired
2043
+ straight to a query param spends one round trip per letter, and on a list screen each letter
2044
+ is several (the page, its count, any sibling query). Keep the input's own value in state and
2045
+ debounce the COMMIT to a second value: the two are genuinely different things, and a screen
2046
+ usually needs both (what is being typed vs. what the results answer). `.cancel()` is for when
2047
+ a trailing call has been overtaken — a picked option, a cleared box. `Combobox` searches
2048
+ through it.
2036
2049
  - **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
2037
2050
  `[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
2038
2051
  pending-state engine for a submit/download/upload action.
@@ -63,7 +63,7 @@ import { pickFiles } from "@lotics/ui/file_picker";
63
63
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
64
64
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
65
65
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
66
- import { Finding, FindingComparison } from "@lotics/ui/finding";
66
+ import { Finding } from "@lotics/ui/finding";
67
67
  import { FormTextInput } from "@lotics/ui/form_text_input";
68
68
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
69
69
  import { ChipGroup, type ChipOption } from "@lotics/ui/chip_group";
@@ -76,7 +76,6 @@ import { CardSelectItem } from "@lotics/ui/card_select_item";
76
76
  import { AgentRun } from "@lotics/ui/agent_run";
77
77
  import { FollowScroll } from "@lotics/ui/follow_scroll";
78
78
  import { useContainerSize } from "@lotics/ui/size_boundary";
79
- import { type SourceRef } from "@lotics/ui/sources";
80
79
  import { DiffValue } from "@lotics/ui/diff_value";
81
80
  import { DiffMark } from "@lotics/ui/diff_mark";
82
81
  import { useChangeSet, type ChangeSet } from "@lotics/ui/use_change_set";
@@ -1371,18 +1370,17 @@ const UNTOUCHED_LINES: { id: string; item: string; qty: string; doc?: LineDoc }[
1371
1370
  // Cross-check findings — severity reads through ONE colored dot badge (red /
1372
1371
  // amber / zinc), the rest stays calm text.
1373
1372
  type Severity = "critical" | "warning" | "info";
1374
- interface Check { id: string; severity: Severity; title: string; detail?: string; comparison?: { values: { label: string; value: string }[]; delta?: string }; sources?: string[] }
1373
+ interface Check { id: string; severity: Severity; title: string; detail?: string; readings?: { source: string; value: string }[]; delta?: string }
1375
1374
  const FINDINGS: Check[] = [
1376
1375
  {
1377
1376
  id: "q1", severity: "critical", title: "Quantity disagrees between the invoice and the packing list",
1378
1377
  detail: "Short-shipping against the invoice risks a customs query and a client claim.",
1379
- comparison: { values: [{ label: "invoice.pdf", value: "480 pcs" }, { label: "packing-list.pdf", value: "440 pcs" }], delta: "−40 pcs" },
1380
- sources: ["invoice.pdf", "packing-list.pdf"],
1378
+ readings: [{ source: "invoice.pdf", value: "480 pcs" }, { source: "packing-list.pdf", value: "440 pcs" }], delta: "−40 pcs",
1381
1379
  },
1382
1380
  {
1383
1381
  id: "q2", severity: "warning", title: "Consignee differs between the invoice and the booking",
1384
1382
  detail: "The invoice names the buyer's own warehouse; the booking routes delivery through a distribution partner. One of them files wrong.",
1385
- sources: ["invoice.pdf", "booking-confirmation.pdf"],
1383
+ readings: [{ source: "invoice.pdf", value: "Buyer warehouse" }, { source: "booking-confirmation.pdf", value: "Distribution partner" }],
1386
1384
  },
1387
1385
  {
1388
1386
  id: "q4", severity: "warning", title: "No certificate of origin among the documents",
@@ -1391,7 +1389,7 @@ const FINDINGS: Check[] = [
1391
1389
  {
1392
1390
  id: "q3", severity: "info", title: "Booking cut-off is earlier than the invoiced ship week",
1393
1391
  detail: "Documents close 14 Jul on the booking; the invoice quotes shipment in the week of 18 Jul. No conflict if the cargo is ready.",
1394
- sources: ["booking-confirmation.pdf"],
1392
+ readings: [{ source: "booking-confirmation.pdf", value: "Cut-off 14 Jul" }, { source: "invoice.pdf", value: "Ships w/c 18 Jul" }],
1395
1393
  },
1396
1394
  ];
1397
1395
 
@@ -5048,8 +5046,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5048
5046
  <View style={{ gap: 14 }}>
5049
5047
  <Text size="md" weight="semibold">Findings</Text>
5050
5048
  {/* Display-only: findings inform the verdict the footer records.
5051
- The kit `Finding` owns severity word, title, detail, the
5052
- PROMINENT metric, Sources; hairlines separate them. */}
5049
+ The kit `Finding` owns the severity dot, the title, the delta
5050
+ and the readings line; hairlines separate them. */}
5053
5051
  {FINDINGS.map((c, i) => (
5054
5052
  <Fragment key={c.id}>
5055
5053
  {i > 0 ? <Divider /> : null}
@@ -5057,11 +5055,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5057
5055
  severity={c.severity}
5058
5056
  title={c.title}
5059
5057
  detail={c.detail}
5060
- sources={(c.sources ?? []).map((name): SourceRef => ({ id: name, label: name, kind: "document" }))}
5058
+ readings={c.readings}
5059
+ delta={c.delta}
5061
5060
  onOpenSource={() => { /* preview stub */ }}
5062
- >
5063
- {c.comparison ? <FindingComparison values={c.comparison.values} delta={c.comparison.delta} /> : null}
5064
- </Finding>
5061
+ />
5065
5062
  </Fragment>
5066
5063
  ))}
5067
5064
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.7.1",
3
+ "version": "44.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -262,6 +262,7 @@
262
262
  "default": "./src/wave_avatar.web.tsx"
263
263
  },
264
264
  "./use_async_fn": "./src/use_async_fn.ts",
265
+ "./use_debounced_callback": "./src/use_debounced_callback.ts",
265
266
  "./use_form": "./src/use_form.ts",
266
267
  "./scroll_to_bottom": "./src/scroll_to_bottom.tsx",
267
268
  "./animation_fade_in": "./src/animation_fade_in.tsx",
@@ -74,7 +74,7 @@ export interface DiffValueProps {
74
74
  * a correction is trivial or alarming; the delta is the thing they were going
75
75
  * to work out anyway. A string, not a number, because only the host knows the
76
76
  * unit, the sign convention and the rounding — the same contract
77
- * `FindingComparison.delta` already uses.
77
+ * `Finding.delta` already uses.
78
78
  */
79
79
  delta?: string;
80
80
  style?: StyleProp<ViewStyle>;
package/src/finding.tsx CHANGED
@@ -1,19 +1,29 @@
1
1
  import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
2
  import { type ReactNode } from "react";
3
- import { Badge } from "./badge";
4
3
  import { Text } from "./text";
5
- import { Sources, type SourceRef } from "./sources";
4
+ import { PressableHighlight } from "./pressable_highlight";
6
5
  import { useLoticsLocale } from "./locale";
7
- import { colors, type ColorName } from "./colors";
6
+ import { solid, type ColorName } from "./colors";
8
7
 
9
8
  export type FindingSeverity = "critical" | "warning" | "info" | "positive";
10
9
 
11
10
  /** The localized strings — the `finding` LoticsLocale slice. */
12
11
  export type FindingLabels = Record<FindingSeverity, string> & {
13
- /** The delta row's label in `FindingComparison`. */
12
+ /** The delta's label, read to assistive tech beside the figure. */
14
13
  difference: string;
15
14
  };
16
15
 
16
+ /**
17
+ * The dot's offset is COMPUTED, not eyeballed: centre it in the title's line
18
+ * box, then add where that box starts inside the row — RN-Web's `Text` sits a
19
+ * couple of px below its parent's top, and ignoring that left the dot 2px high
20
+ * even once the centring was right. Both numbers were measured off the rendered
21
+ * row rather than guessed; a guess put it 4.5px out.
22
+ */
23
+ const TITLE_LINE = 24;
24
+ const TITLE_INSET = 2;
25
+ const DOT = 7;
26
+
17
27
  const SEVERITY_COLOR: Record<FindingSeverity, ColorName> = {
18
28
  critical: "red",
19
29
  warning: "amber",
@@ -21,121 +31,164 @@ const SEVERITY_COLOR: Record<FindingSeverity, ColorName> = {
21
31
  positive: "emerald",
22
32
  };
23
33
 
34
+ /** One reading: what a source says, and which source said it. */
35
+ export interface FindingReading {
36
+ /** Where it came from — a document, a table, a rate card. */
37
+ source: string;
38
+ /** What that source says the value is. */
39
+ value: string;
40
+ }
41
+
24
42
  export interface FindingProps {
25
- /** Ranked severity — the dot badge + its localized word. */
43
+ /** Ranked severity — sets the dot's colour and its localized word. */
26
44
  severity: FindingSeverity;
27
- /** What the agent found one line. */
45
+ /** What was found, in one line, in the reader's own nouns. */
28
46
  title: string;
29
- /** The explanation under the title. */
47
+ /**
48
+ * The readings that disagree, in source order. Rendered inline beneath the
49
+ * title as `source value · source value`.
50
+ *
51
+ * NEITHER is marked as the wrong one. A finding reports that two sources
52
+ * disagree; which one is right is the reader's call, and a struck-through or
53
+ * red-inked side would make that call for them.
54
+ */
55
+ readings?: FindingReading[];
56
+ /**
57
+ * The computed difference — "+170 USD", "−40 pcs", "3 days late".
58
+ *
59
+ * The most actionable thing on a finding, which is why it sits right-aligned
60
+ * on the title's own line rather than below the readings: it is the figure
61
+ * the reader acts on, and `1,450 → 1,280` makes them do the subtraction
62
+ * themselves. Omit where the fields are not commensurable — two spellings of
63
+ * a name have no delta.
64
+ */
65
+ delta?: string;
66
+ /**
67
+ * The CONSEQUENCE, when the title does not already carry it — what happens if
68
+ * this ships. Optional on purpose: a second sentence that restates the title
69
+ * in longer words costs a line and adds nothing.
70
+ */
30
71
  detail?: string;
31
- /** The emphasized MEASURE of the finding ("−40 pcs") prominent in the
32
- * body (lg, semibold), never a side note. */
33
- metric?: string;
34
- /** What the metric compares ("invoice vs packing list"). */
35
- metricCaption?: string;
36
- /** Provenance — the one `Sources` idiom, chips at the bottom. */
37
- sources?: SourceRef[];
38
- onOpenSource?: (source: SourceRef) => void;
39
- /** Compose anything extra between the body and the sources — a band pair,
40
- * an action row, a custom block. */
72
+ /** Makes each source name pressable the host navigates to it. */
73
+ onOpenSource?: (source: string) => void;
74
+ /** Anything richer than readings — a table, a chart, a confidence bar. */
41
75
  children?: ReactNode;
42
76
  style?: StyleProp<ViewStyle>;
43
77
  }
44
78
 
45
79
  /**
46
- * One ranked insight from an AI check — a cross-check discrepancy, an audit
47
- * observation, a briefing item. Good defaults (severity word, title, detail,
48
- * the PROMINENT metric, `Sources` chips) with a `children` slot for any
49
- * extra composition. Display-only: a finding informs the verdict the host
50
- * records; it decides nothing itself. Stack several most-severe first,
51
- * separated by a `Divider`.
80
+ * ONE RANKED INSIGHT from an AI check — a cross-check discrepancy, an audit
81
+ * observation, a briefing item.
82
+ *
83
+ * The readings sit at `sm`, the same size as the title. They were `xs` — a
84
+ * miniature that said "secondary" by being hard to read, which is the wrong
85
+ * trade for the values the whole finding rests on. Hierarchy comes from WEIGHT
86
+ * and colour here, never from shrinking the evidence.
87
+ *
88
+ * Laid out HORIZONTALLY, in the order a reader needs it: the dot says whether
89
+ * to care, the title says what is wrong, the delta on that same line says how
90
+ * far off, and one dim line underneath says who said what. Two lines carry all
91
+ * four, so a reader scanning a stack reads severity down the left edge and
92
+ * magnitude down the right.
93
+ *
94
+ * The predecessor stacked six blocks — badge, title, detail, a labelled row per
95
+ * side, a hairline, the delta, then source chips. It spent roughly 200px to say
96
+ * one sentence and one figure, buried the delta in the middle where nothing
97
+ * scans it, and named every source twice: once in the comparison rows, again in
98
+ * the chips below them. The readings line IS the provenance, so the chips went
99
+ * with it, and `FindingComparison` is gone — its job is the `readings` prop.
100
+ *
101
+ * **Display-only**: a finding informs the action the human takes; it decides
102
+ * nothing itself. Stack several most-severe first.
52
103
  */
53
104
  export function Finding(props: FindingProps) {
54
105
  const words = useLoticsLocale().finding;
106
+ const readings = props.readings ?? [];
107
+
55
108
  return (
56
109
  <View style={[styles.root, props.style]}>
57
- <Badge variant="dot" color={SEVERITY_COLOR[props.severity]} label={words[props.severity]} />
58
- <Text size="sm" weight="medium">
59
- {props.title}
60
- </Text>
61
- {props.detail ? (
62
- <Text size="sm" color="muted">
63
- {props.detail}
64
- </Text>
65
- ) : null}
66
- {props.metric ? (
67
- <View style={styles.metricBlock}>
68
- <Text size="lg" weight="semibold" tabular>
69
- {props.metric}
110
+ <View
111
+ style={[styles.dot, { backgroundColor: solid(SEVERITY_COLOR[props.severity]) }]}
112
+ accessibilityLabel={words[props.severity]}
113
+ />
114
+
115
+ <View style={styles.body}>
116
+ <View style={styles.headline}>
117
+ <Text size="sm" weight="medium" style={styles.title}>
118
+ {props.title}
70
119
  </Text>
71
- {props.metricCaption ? (
72
- <Text size="xs" color="muted">
73
- {props.metricCaption}
120
+ {props.delta ? (
121
+ <Text
122
+ size="md"
123
+ weight="semibold"
124
+ tabular
125
+ style={[styles.delta, { color: solid(SEVERITY_COLOR[props.severity]) }]}
126
+ accessibilityLabel={`${words.difference}: ${props.delta}`}
127
+ >
128
+ {props.delta}
74
129
  </Text>
75
130
  ) : null}
76
131
  </View>
77
- ) : null}
78
- {props.children}
79
- {props.sources && props.sources.length > 0 ? (
80
- <Sources label={null} sources={props.sources} onOpen={props.onOpenSource} />
81
- ) : null}
132
+
133
+ {readings.length > 0 ? (
134
+ <View style={styles.readings}>
135
+ {readings.map((reading, index) => (
136
+ <View key={`${reading.source}-${index}`} style={styles.reading}>
137
+ {index > 0 ? (
138
+ <Text size="sm" color="muted">
139
+ ·
140
+ </Text>
141
+ ) : null}
142
+ <SourceName label={reading.source} onOpen={props.onOpenSource} />
143
+ <Text size="sm" weight="medium" tabular numberOfLines={1}>
144
+ {reading.value}
145
+ </Text>
146
+ </View>
147
+ ))}
148
+ </View>
149
+ ) : null}
150
+
151
+ {props.detail ? (
152
+ <Text size="sm" color="muted">
153
+ {props.detail}
154
+ </Text>
155
+ ) : null}
156
+
157
+ {props.children}
158
+ </View>
82
159
  </View>
83
160
  );
84
161
  }
85
162
 
86
- export interface FindingComparisonProps {
87
- /** The disagreeing sides each value with where it came from
88
- * ("invoice.pdf", "480 pcs"). Two or more. */
89
- values: { label: string; value: string }[];
90
- /** The computed difference, EMPHASIZED ("−40 pcs"). */
91
- delta?: string;
92
- /** Label for the delta row. Defaults to the localized "Difference". */
93
- deltaLabel?: string;
94
- }
95
-
96
- /**
97
- * The expected-vs-actual body of a `Finding` — each side a labeled row
98
- * (source, value), a hairline, then the DELTA emphasized. Compose it as the
99
- * finding's children; it replaces ad-hoc metric text for any
100
- * one-value-disagrees insight (quantities, totals, dates).
101
- */
102
- export function FindingComparison(props: FindingComparisonProps) {
103
- const words = useLoticsLocale().finding;
163
+ /** The source behind a reading. Pressable only when the host can navigate to
164
+ * it an affordance on something that goes nowhere is a broken promise. */
165
+ function SourceName({ label, onOpen }: { label: string; onOpen?: (source: string) => void }) {
166
+ const name = (
167
+ <Text size="sm" color="muted" numberOfLines={1}>
168
+ {label}
169
+ </Text>
170
+ );
171
+ if (!onOpen) return name;
104
172
  return (
105
- <View style={styles.comparison}>
106
- {props.values.map((v, i) => (
107
- <View key={`${v.label}-${i}`} style={styles.comparisonRow}>
108
- <Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
109
- {v.label}
110
- </Text>
111
- <Text size="sm" weight="semibold" tabular numberOfLines={1} style={{ flexShrink: 1 }}>
112
- {v.value}
113
- </Text>
114
- </View>
115
- ))}
116
- {props.delta ? (
117
- <>
118
- <View style={styles.comparisonRule} />
119
- <View style={styles.comparisonRow}>
120
- <Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
121
- {props.deltaLabel ?? words.difference}
122
- </Text>
123
- <Text size="lg" weight="semibold" tabular>
124
- {props.delta}
125
- </Text>
126
- </View>
127
- </>
128
- ) : null}
129
- </View>
173
+ <PressableHighlight onPress={() => onOpen(label)} accessibilityRole="link" accessibilityLabel={label}>
174
+ {name}
175
+ </PressableHighlight>
130
176
  );
131
177
  }
132
178
 
133
179
  const styles = StyleSheet.create({
134
- root: { gap: 6, alignItems: "flex-start" },
135
- metricBlock: { gap: 1, paddingVertical: 2 },
136
- comparison: { gap: 6, paddingVertical: 4, alignSelf: "stretch", maxWidth: 420 },
137
- comparisonRow: { flexDirection: "row", alignItems: "baseline", gap: 12 },
138
- comparisonLabel: { width: 130 },
139
- comparisonRule: { height: 1, backgroundColor: colors.zinc[100] },
180
+ root: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
181
+ // See TITLE_LINE above — centred on the title's line box, plus where that box
182
+ // begins in the row.
183
+ dot: { width: DOT, height: DOT, borderRadius: DOT / 2, marginTop: TITLE_INSET + (TITLE_LINE - DOT) / 2 },
184
+ body: { flex: 1, gap: 3, minWidth: 0 },
185
+ headline: { flexDirection: "row", alignItems: "baseline", gap: 12 },
186
+ title: { flex: 1, minWidth: 0 },
187
+ // Never shrinks: the figure is the point of the row, and a clipped delta is
188
+ // worse than a title that wraps. Its COLOUR is the severity's, set on the
189
+ // instance — the dot marks the left edge and the figure the right, so a
190
+ // reader scanning a stack gets rank and magnitude in one pass.
191
+ delta: { flexShrink: 0 },
192
+ readings: { flexDirection: "row", flexWrap: "wrap", alignItems: "baseline", gap: 6 },
193
+ reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
140
194
  });
141
-