@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 +38 -0
- package/docs/ai_patterns.md +18 -11
- package/docs/catalog.md +12 -9
- package/examples/tpl_record.tsx +61 -116
- package/package.json +1 -1
- package/src/diff_value.tsx +1 -1
- package/src/finding.tsx +147 -94
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
|
package/docs/ai_patterns.md
CHANGED
|
@@ -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
|
|
554
|
-
`title`,
|
|
555
|
-
`
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
the
|
|
356
|
-
|
|
357
|
-
|
|
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 `
|
|
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` + `
|
|
1994
|
-
|
|
1995
|
-
|
|
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),
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2935
|
-
|
|
2936
|
-
|
|
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,
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
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 —
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
the
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
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 —
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
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
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
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
|
|
5104
|
-
|
|
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
|
-
|
|
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
package/src/diff_value.tsx
CHANGED
|
@@ -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
|
-
* `
|
|
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 {
|
|
4
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
5
|
import { useLoticsLocale } from "./locale";
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
43
|
+
/** Ranked severity — sets the dot's colour and its localized word. */
|
|
26
44
|
severity: FindingSeverity;
|
|
27
|
-
/** What
|
|
45
|
+
/** What was found, in one line, in the reader's own nouns. */
|
|
28
46
|
title: string;
|
|
29
|
-
/**
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
47
|
-
* observation, a briefing item.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
<
|
|
58
|
-
|
|
59
|
-
{props.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
72
|
-
<Text
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
<
|
|
106
|
-
{
|
|
107
|
-
|
|
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:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|