@lotics/ui 43.7.0 → 44.0.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),
@@ -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
 
@@ -2931,20 +2929,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2931
2929
  divider above the section for every section before it. */}
2932
2930
  {/* The header band sits ABOVE the stack, on the canvas beat — it is the
2933
2931
  record's IDENTITY, not a section, so the 56px between-sections rhythm
2934
- (and, in scroll mode, the hairline that comes with it) does not apply.
2935
- Every condition it used to announce now lives on the field it concerns,
2936
- with a dot on the rail saying which section to look in. */}
2932
+ (and, in scroll mode, its hairline) does not apply. A condition belongs
2933
+ on the FIELD it concerns, with a dot on the rail naming the section to
2934
+ look in never announced up here away from what it is about. */}
2937
2935
  <View style={{ flex: 1, minWidth: 0, gap: 28 }}>
2938
2936
  {/* The record's own ACTS live here, beside its identity — not inside a
2939
- section. Dropping a file is something you do TO THE RECORD, but its
2940
- only affordance used to sit in the Files section, which scoped the
2941
- discoverability to that section: from anywhere else on the page
2942
- (or from 3,975px above it) nothing said the record took files at
2943
- all, and a whole-record drop target nobody can see is an
2944
- accelerator, never an entry point. The Files register keeps its own
2945
- Add a register's Add belongs under its register this is the
2946
- record-level way in. A template composition for now; it earns a kit
2947
- slot on RecordSummary at its second consumer. */}
2937
+ section. Dropping a file is something you do TO THE RECORD, so an
2938
+ affordance for it that lives only in the Files section is scoped to
2939
+ that section: from anywhere else on a long page nothing says the
2940
+ record takes files at all, and a whole-record drop target nobody can
2941
+ see is an accelerator, never an entry point. The Files register keeps
2942
+ its own Add a register's Add belongs under its register — and this
2943
+ is the record-level way in. A template composition for now; it earns a
2944
+ kit slot on RecordSummary at its second consumer. */}
2948
2945
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
2949
2946
  <View style={{ flex: 1, minWidth: 0 }}>
2950
2947
  {/* The SUBJECT leads, the key goes under it. A reader talks about
@@ -3165,31 +3162,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3165
3162
  />
3166
3163
  ) : (
3167
3164
  <>
3168
- {/* AN ENTRY, NOT A LOG LINE — and that distinction is the whole
3169
- reason this stopped being a `Timeline`.
3170
-
3171
- `Timeline` puts its label INSIDE the row's press target. That
3172
- is right for an event ("Stage changed to Won") whose text is
3173
- derived and read-only. It is wrong here, because the gist is
3174
- the one thing on an entry a person WROTE, and text inside a
3175
- button can never be edited where it sits. Four repairs came out
3176
- of working around that the verbs moved off the row, the label
3177
- learned to un-clamp, the body's copy of the gist came out, and
3178
- the Edit verb ended up a scroll away from the sentence it
3179
- edits. They were never independent: each one was the
3180
- arrangement pushing back.
3181
-
3182
- So the gist is not a label. It is a RESIDENT field — always
3183
- editable, in place, no mode and no Edit button, the same as
3184
- every other authored value on this record. The old argument
3185
- against a resident editor here ("the gist is already the row's
3186
- label, so it renders the sentence twice") disappears the moment
3187
- it stops being one.
3188
-
3189
- What is left is the shape every comment UI converged on: a
3190
- metadata header, the prose under it, the row's own actions at
3191
- the TOP RIGHT beside the text they act on, and the heavy
3192
- artifacts behind their own disclosure. */}
3165
+ {/* AN ENTRY, NOT A LOG LINE — which is why this is not a `Timeline`.
3166
+ `Timeline` puts its label INSIDE the row's press target, right
3167
+ for a derived read-only event ("Stage changed to Won") and wrong
3168
+ for a sentence a person WROTE: text inside a button cannot be
3169
+ edited where it sits.
3170
+
3171
+ So the gist is a RESIDENT field always editable in place, no
3172
+ mode, no Edit button, like every other authored value here. The
3173
+ shape every comment UI converges on: a metadata header, the prose
3174
+ under it, the row's actions at the TOP RIGHT beside the text they
3175
+ act on, and heavy artifacts behind their own disclosure. */}
3193
3176
  <View style={{ gap: 20 }}>
3194
3177
  {(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a) => (
3195
3178
  <ActivityEntryRow
@@ -3712,52 +3695,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3712
3695
  </Section>
3713
3696
  </View>
3714
3697
 
3715
- {/* PHOTOS — a second SET, and therefore the other SURFACE.
3716
-
3717
- Files above is a row LIST because a shipping document is identified by
3718
- its NAME: a PDF thumbnail is a grey page identical to every other grey
3719
- page, so a grid of them is a wall of one tile. A photo is identified
3720
- by its CONTENT `IMG_4471.jpg` tells nobody anything so the tile IS
3721
- the identity and a grid puts twelve on screen at once.
3722
- (AGENTS.md: documents get a LIST, images get a GRID.)
3723
-
3724
- BOTH SURFACES SHOW THE WHOLE COLLECTION — they are two LENSES over
3725
- one set, not two sets. Files answers "what is on this record" in a
3726
- scannable column; Photos answers "what did it LOOK like" at a glance.
3727
- A reader who guesses wrong still finds the artifact instead of
3728
- concluding it was never filed, and nobody has to know a file's FORMAT
3729
- before they know where to look for it.
3730
-
3731
- The grid used to filter to `image/*`. That made the split a fact about
3732
- MIME rather than about reading, and it put the signed delivery note in
3733
- a different section from the photos of the same hand-over while this
3734
- very comment promised that the odd `.docx` among forty phone photos
3735
- would stay in the grid. It could not: the filter forbade what the
3736
- sentence described. `FileThumbnail` already renders a non-image as a
3737
- doc tile (badge + filename), so nothing had to be built — only the
3738
- filter that hid it had to go.
3739
-
3740
- THE COST IS REAL AND DELIBERATE: every file now appears twice. The
3741
- trade is that duplication is cheap to skim past, whereas an artifact
3742
- filed under the wrong lens looks like an artifact that was never
3743
- filed. A record whose attachments are overwhelmingly paperwork should
3744
- drop the grid entirely rather than carry a wall of identical tiles —
3745
- the choice is per-record-TYPE, made once, not per file.
3746
-
3747
- Ordering follows the collection, so a record heavy in documents leads
3748
- this grid with grey tiles. If that reads badly for your record type,
3749
- sort images first rather than reinstating the filter.
3750
-
3751
- The tiles are mixed by construction now, so the FIXTURE has to be
3752
- mixed too — a grid of eight JPGs never once renders the doc tile it
3753
- has always been able to render, which is how the gap survived.
3754
-
3755
- The ADD rides the heading row, right edge, exactly as it does on Files
3756
- and Fees — a section's add sits in the same spot whether the section
3757
- holds nought or forty. Note it is a plain `Button` + `pickFiles`, not
3758
- `FilesEditorUpload`: that verb reads the `FilesEditor` context, and the
3759
- heading is OUTSIDE the editor by construction, so a compound's own
3760
- upload button can never reach the one place the law puts it. */}
3698
+ {/* PHOTOS — two LENSES over ONE set, not two sets. Files answers "what is
3699
+ on this record" in a scannable column; this answers "what did it LOOK
3700
+ like" at a glance. (List for documents, grid for images: AGENTS.md.)
3701
+
3702
+ BOTH show the WHOLE collection, unfiltered. A reader who guesses wrong
3703
+ still finds the artifact instead of concluding it was never filed, and
3704
+ nobody has to know a file's FORMAT before knowing where to look. The
3705
+ cost is real: every file appears twice. Duplication is cheap to skim
3706
+ past; an artifact filed under the wrong lens reads as missing.
3707
+
3708
+ Ordering follows the collection, so a document-heavy record leads with
3709
+ grey tiles sort images first if that reads badly, and drop the grid
3710
+ entirely for a record type that is overwhelmingly paperwork. That
3711
+ choice is per record TYPE, made once, never per file.
3712
+
3713
+ The add is a plain `Button` + `pickFiles`, NOT `FilesEditorUpload`:
3714
+ that verb reads the `FilesEditor` context and the heading row sits
3715
+ outside the editor by construction, so the compound's own upload
3716
+ button cannot reach the one place the law puts it. */}
3761
3717
  <View onLayout={nav.register("photos")}>
3762
3718
  <Section>
3763
3719
  <SectionHeading>
@@ -4147,26 +4103,16 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
4147
4103
  <InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
4148
4104
  </DetailRow>
4149
4105
  </DetailTable>
4150
- {/* The destructive verb, LEFT — the peek footer's
4151
- convention, which is the kit's. It replaces the
4152
- DrawerFooter that used to pin it.
4153
-
4154
- Solid `danger`, which is the destructive convention
4155
- page-wide (composition rule 9) not a text-weight act.
4156
- A quiet destructive verb had been tried here and the
4157
- surface-less rung is gone: an act now always carries a
4158
- control, and only NAVIGATION is underlined text. The trash
4159
- glyph stays off "Remove fee" already names the object,
4160
- which is the safeguard the icon was only decorating.
4161
-
4162
- NO Divider. Every hairline a `Table` draws is
4163
- full-bleed (the row `Divider`s carry no padding, the
4164
- header band's border spans its whole box), so one
4165
- inset by ROW_GUTTER puts two hairline lengths in the
4166
- same vertical run — and it would be saying twice what
4167
- the expanded row's own wash already says. The row
4168
- wrapper is required, not decoration: a `Button` alone
4169
- in a column View stretches to full width. */}
4106
+ {/* The destructive verb, LEFT — the peek footer's convention.
4107
+ Solid `danger` per composition rule 9; no trash glyph,
4108
+ because "Remove fee" already names the object the icon
4109
+ was only decorating.
4110
+
4111
+ NO Divider. Every hairline a `Table` draws is full-bleed,
4112
+ so one inset by ROW_GUTTER puts two hairline lengths in
4113
+ the same vertical run and it repeats what the expanded
4114
+ row's wash already says. The row wrapper IS required: a
4115
+ `Button` alone in a column View stretches full width. */}
4170
4116
  <View style={{ flexDirection: "row" }}>
4171
4117
  <Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
4172
4118
  </View>
@@ -5100,8 +5046,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5100
5046
  <View style={{ gap: 14 }}>
5101
5047
  <Text size="md" weight="semibold">Findings</Text>
5102
5048
  {/* Display-only: findings inform the verdict the footer records.
5103
- The kit `Finding` owns severity word, title, detail, the
5104
- 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. */}
5105
5051
  {FINDINGS.map((c, i) => (
5106
5052
  <Fragment key={c.id}>
5107
5053
  {i > 0 ? <Divider /> : null}
@@ -5109,11 +5055,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5109
5055
  severity={c.severity}
5110
5056
  title={c.title}
5111
5057
  detail={c.detail}
5112
- sources={(c.sources ?? []).map((name): SourceRef => ({ id: name, label: name, kind: "document" }))}
5058
+ readings={c.readings}
5059
+ delta={c.delta}
5113
5060
  onOpenSource={() => { /* preview stub */ }}
5114
- >
5115
- {c.comparison ? <FindingComparison values={c.comparison.values} delta={c.comparison.delta} /> : null}
5116
- </Finding>
5061
+ />
5117
5062
  </Fragment>
5118
5063
  ))}
5119
5064
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.7.0",
3
+ "version": "44.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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
-