@lotics/ui 4.9.0 → 5.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/AGENTS.md CHANGED
@@ -80,13 +80,14 @@ Pick by capability, not by name. (→ the source file for the API.)
80
80
  - **AI surfaces** — `Composer` (the adaptive command/chat composer — a compact pill when empty that
81
81
  expands for long text + attachments; the surface that triggers agent work),
82
82
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
83
- form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
84
- + `Confidence`, `RecordReview` (an extracted record reviewed + edited before save — fields
85
- click-to-edit, Confirm collapses it), `ChangeReview` (before→after for an agent-proposed edit
86
- whole-set OR 1-by-1), `Clarify` (the agent asks back — selectable `ChoiceList` options),
87
- `Sources` (provenance chips for AI output). For STRUCTURED AI output the four review
83
+ form — a composer's "working" state), `ReviewCard` (THE single-proposal review card — compositional
84
+ body via `children`: a `RecordFields` record (header-first) or a `MatchSides` pairing (body-first))
85
+ + `Confidence`; `ChangeReview` (the BATCH review engine — whole-set OR 1-by-1; compositional via
86
+ `renderItem`, with `ChangeDiff` for the before→after body),
87
+ `Clarify` (the agent asks back selectable `ChoiceList` options),
88
+ `Sources` (provenance chips for AI output). For STRUCTURED AI output the review
88
89
  surfaces above don't cover: `SpecList` (the "exact breakdown" — a labeled-value spec sheet, the
89
- panel of an answer), `MatchRow` (a two-sided AI pairing — source ↔ proposed match + confidence),
90
+ panel of an answer),
90
91
  `Finding` (one ranked briefing/audit insight — severity + metric + sources + action), `Discrepancy`
91
92
  (a field whose value disagrees across sources — pick the truth), `TriageRow` (an incoming item the
92
93
  agent classified + routed), `ScoredOption` (an AI-ranked candidate — score + rationale + specs).
@@ -243,15 +244,23 @@ Compose the surfaces as a loop, and reach for the right one by job:
243
244
  intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
244
245
  collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
245
246
  morphs into it while running, and reveals again when done.
246
- - **Review before apply**`Suggestion` (a NEW proposed value accept / edit / dismiss, carrying
247
- a `Confidence` chip) or `ChangeReview` (an EDIT to existing state before→after, apply / discard).
248
- For a shortlist, stack ranked `Suggestion`s (highest confidence first). `ChangeReview` also has a
249
- **1-by-1** modepass `onAcceptItem`/`onRejectItem` and each suggested change gets its own ✓/✕,
250
- Apply committing only the accepted set (the chat copilot). Nothing auto-applies.
251
- - **Review an extracted record** `RecordReview`: one record the agent pulled from a document, its
252
- fields click-to-edit (`InlineTextInput`), with a confidence + Confirm / Remove; Confirm collapses
253
- it to a checklist row (Undo to re-open). Stack several under a "Save all" to capture a whole
254
- document table (see `tpl_extract`). Nothing writes until the human confirms + saves.
247
+ - **Review before apply — ONE card + the batch engine, the BODY is compositional.** Every "the agent
248
+ proposes the human accepts / edits / dismissesnothing auto-applies" surface wears the same
249
+ `reviewCardStyle` + an optional `Confidence`. There are two primitives:
250
+ - **`ReviewCard`**THE single-proposal review card. It owns the chrome (eyebrow + `Confidence`,
251
+ rationale, a generic `actions` footer Accept/Edit/Dismiss, Confirm/Remove, Accept/Reassign,
252
+ whatever the verbs areand a collapse-to-a-row resolved state); the BODY is `children`. **Layout
253
+ follows the props**: give it an `eyebrow`/`title` and the chrome LEADS (header-first a value or
254
+ record proposal, e.g. a `RecordFields` `tpl_extract`); give it only `children` and the BODY leads,
255
+ the confidence + reason beneath it (body-first — a `MatchSides` pairing where the link is the hero
256
+ → `tpl_match`). Stack several (rank by confidence) for a shortlist or a document's records.
257
+ - **`ChangeReview`** — THE batch engine for a SET you decide each then commit together. `renderItem`
258
+ draws each proposal (a `ChangeDiff` before→after body, a record, an editable form), 1-by-1
259
+ Keep/Drop collapses each, "Accept all" + Apply commits only the kept (e.g. ingested rate lines —
260
+ `tpl_ratedesk`); whole-set mode applies/discards the lot.
261
+ The body atoms (`RecordFields`, `MatchSides`, `ChangeDiff`) are pure presentation — usable inside
262
+ either engine OR standalone (a live-edit field card). The decide / accept-all / apply mechanics are
263
+ never re-implemented per surface.
255
264
  - **Ask back** — `Clarify`: when the agent is unsure, it asks a question with quick-reply options
256
265
  and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run.
257
266
  - **Provenance** — `Sources`: openable chips saying where the output came FROM (records, a
@@ -267,7 +276,7 @@ a conversation, a single pass, or generated prose:
267
276
  - **Canvas** (`tpl_dieline`) — the page IS the design on a pannable/zoomable surface: it stays
268
277
  CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom (a
269
278
  `Composer`: attach a photo → review/remove it → add a note → send, with `sendDisabled` allowing a
270
- file-and/or-text submit — NOT auto-run on attach) morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (the `RecordReview` field-card
279
+ file-and/or-text submit — NOT auto-run on attach) morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (a `RecordFields` field-card
271
280
  in live-edit mode, carrying the single Download; minimizes to a pill) floats centre-right. Panel, zoom pill and composer
272
281
  float ON TOP — they never shift the design. (Floating layers use a `pointerEvents:"none"` wrapper
273
282
  with the interactive child set `"auto"`; RN-Web ignores `"box-none"` in style, so a full-width
@@ -277,22 +286,33 @@ a conversation, a single pass, or generated prose:
277
286
  - **Chat** (`tpl_assistant`) — a conversation where the agent's answer carries suggested edits the
278
287
  user approves/rejects **1-by-1** (`ChangeReview` per-item), applying only the accepted set. An AI
279
288
  copilot that proposes changes to a record and never commits on its own.
280
- - **One-shot** (`tpl_extract`, `RecordReview`) — read an image → the agent extracts the records →
281
- review each (`RecordReview`: fields click-to-edit, Confirm collapses it to a checklist row) →
282
- Save all to the table. A data-capture pass; nothing stored until confirmed.
289
+ - **One-shot** (`tpl_extract`) — read an image → the agent extracts the records → review each as a
290
+ `ReviewCard` wrapping a `RecordFields` body (fields click-to-edit, Confirm collapses it to a
291
+ checklist row) → Save all to the table. A data-capture pass; nothing stored until confirmed.
292
+ - **Bulk ingest desk** (`tpl_ratedesk`) — the page IS a filterable register (a From → To route picker
293
+ + `ColumnFilter` pills → `Card padding:0` + full-bleed `Table`), the register the hero. Updating
294
+ prices is ONE header CTA that opens a Dialog wizard: drop SEVERAL source docs at once → the agent
295
+ reads them all (`AgentRun`) → a `ChangeReview` 1-by-1 (each proposal a Keep/Drop card, New / Updated
296
+ with old→new delta, "Accept all" + Apply) → the kept rows land in the register. Pairs `tpl_extract`'s
297
+ ingest with `tpl_directory`'s register. For a maintained reference table — rate card, price list,
298
+ tariff, catalogue: re-ingest the source sheets beats hand-typing.
283
299
  - **Draft** (`tpl_draft`) — the agent writes a first draft from context; the OUTPUT itself streams
284
300
  (the text fills in live), then becomes an editable surface the human refines before sending.
285
301
  Tone/length steer it. For generated prose — a reply, a quote, a listing.
286
302
 
287
303
  The next six output STRUCTURE the first four can't — when the answer is a panel of facts, a queue of
288
304
  decisions, or a ranked set, don't cram it into chat prose. Each pairs a template with a primitive:
289
- - **Answer desk** (`tpl_lookup`, `SpecList`) — converse on the LEFT, a pinned STRUCTURED answer on
290
- the RIGHT (a verdict header + an exact breakdown + the policies + sources); a follow-up refines the
291
- same panel. For look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a spec/compliance desk. The
292
- structured answer is the product; chat just steers. (NOT a chat with the answer buried in a bubble.)
293
- - **Match** (`tpl_match`, `MatchRow`) the agent proposes PAIRINGS with its reasoning; each row is
294
- two-sided (source proposed counterpart + confidence), accept / reassign / reject, high-confidence
295
- batch-accept. The AI reconciliation / dedup / correlation queue. (The deterministic cousin
305
+ - **Answer desk** (`tpl_lookup`, `SpecList`) — describe the goods on the LEFT the agent RANKS the
306
+ matching codes (NEAREST MATCHES, the top one Recommended) pick one and its STRUCTURED answer pins on
307
+ the RIGHT (a verdict header + an exact breakdown + the policies + sources). Input → matches → pick,
308
+ NOT a single confident verdict: classification is ambiguous, so the alternatives are first-class and
309
+ picking a different one changes the duty; refining the description re-ranks. For look-up-and-explain:
310
+ tariff/HS, fee lookup, policy Q&A, a spec/compliance desk. (NOT a chat with the answer in a bubble.)
311
+ - **Match** (`tpl_match`, body-first `ReviewCard` + `MatchSides`) the agent proposes PAIRINGS with
312
+ its reasoning; each is a `ReviewCard` (no title, so body-first) whose body is a two-sided `MatchSides`
313
+ pairing (source → proposed counterpart), with the confidence + reason beneath, accept / reassign /
314
+ reject, high-confidence batch-accept. The AI reconciliation / dedup / correlation queue. (The
315
+ deterministic cousin
296
316
  `tpl_reconcile` has no agent reasoning — that's the tell for which to reach for.)
297
317
  - **Briefing** (`tpl_briefing`, `Finding`) — a generated narrative over the data + a RANKED list of
298
318
  what needs attention, each finding carrying a severity, a metric, its sources, and one action. The
@@ -425,6 +445,19 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
425
445
  · `FilterPill` is THE secondary-dimension filter — a compact pill that opens a composed popover
426
446
  editor (`PickerMenu multi` / `RangeSlider` / `Counter` / date range). Single-select closes on
427
447
  pick via the `{({close}) => …}` render prop.
448
+ · `ColumnFilter` is the TYPED column filter — give it a `FilterableColumn` (`text` / `number` /
449
+ `select`) + a controlled `ColumnFilterValue` and it renders the right editor (contains / range /
450
+ multi-select) inside a `FilterPill`, with `columnFilterToConditions` mapping the value to query
451
+ predicates. Reach for it — ONE compact pill per dimension — when a register filters on SEVERAL
452
+ columns (origin, destination, carrier, type…). Never hand-roll a row of bespoke chips/filters,
453
+ and never use a permanently-expanded `ChipGroup` for a secondary dimension.
454
+ · `Combobox pill` is the SEARCHABLE filter in the band — `<Combobox pill>` wears the same white
455
+ toolbar-pill surface as `SearchInput`/`FilterPill` (don't put a bare form-look Combobox in a
456
+ toolbar — its transparent input reads grey on the zinc-50 canvas beside the white pills). Reach
457
+ for it over `ColumnFilter` when the value is ONE of a large/growing set you type to narrow (a
458
+ port, a customer) rather than a short fixed list. Pass `recentOptions` (the same option list) so
459
+ a click opens the full list, then typing filters. The **From → To route picker** is two of them
460
+ (`icon="map-pin"`, `clearable`) with an `arrow-right` between — flight-search style.
428
461
  · `Tabs` switch between VIEWS/sections (different content/layout) — never a subset of one list.
429
462
  `SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
430
463
  - **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
@@ -490,11 +523,13 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
490
523
  reveals CENTRED on a pannable/zoomable surface, the floating composer morphing into `AgentProgress`,
491
524
  a pinned live-edit params panel centre-right; prompt OR edit a param to iterate) ·
492
525
  `tpl_assistant` (a chat copilot: the agent's answer carries suggested edits approved/rejected
493
- 1-by-1) · `tpl_extract` (read an image → extract records → review/edit each (`RecordReview`)
494
- Save all) · `tpl_draft` (draft generation: context the reply text streams into an editable surface
526
+ 1-by-1) · `tpl_extract` (read an image → extract records → review/edit each (`ReviewCard` +
527
+ `RecordFields`) → Save all) · `tpl_ratedesk` (BULK INGEST desk: a register-as-hero with an "Import
528
+ prices" CTA → a Dialog wizard: drop several sheets → `ChangeReview` 1-by-1 → kept rows land in the
529
+ register; for a maintained rate card / price list / tariff) · `tpl_draft` (draft generation: context → the reply text streams into an editable surface
495
530
  → refine → send; tone/length steer it). *Structure*: `tpl_lookup` (the ANSWER desk: converse left,
496
531
  a pinned `SpecList` verdict panel right; a follow-up refines it) · `tpl_match` (AI reconciliation:
497
- `MatchRow` pairings with reasoning, accept/reassign/reject) · `tpl_briefing` (a generated narrative
532
+ body-first `ReviewCard` + `MatchSides` pairings with reasoning, accept/reassign/reject) · `tpl_briefing` (a generated narrative
498
533
  + a ranked `Finding` attention list) · `tpl_crosscheck` (the audit desk: `Discrepancy` cards where
499
534
  the documents disagree) · `tpl_triage` (an inbox of `TriageRow`s the agent classified + routed) ·
500
535
  `tpl_compare` (ranked `ScoredOption` candidates the agent scored).
@@ -516,7 +551,9 @@ inline_text_input · inline_number_input · inline_select · inline_member_selec
516
551
  inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
517
552
  `InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderValue` for a chip/badge at rest) ·
518
553
  list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
519
- floating_action_bar · filter_pill · sort_header · table · pagination · accordion · stepper ·
554
+ floating_action_bar · filter_pill · column_filter (ColumnFilter the typed per-column filter pill +
555
+ columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
556
+ sort_header · table · pagination · accordion · stepper ·
520
557
  step_progress · step_list · timeline · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
521
558
  command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
522
559
  CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
@@ -527,11 +564,11 @@ file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator
527
564
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
528
565
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
529
566
  expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
530
- composer: a compact pill that expands for long text + attachments) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
531
- record_review (RecordReviewan extracted record reviewed + edited before save) · change_review (before→after — whole-set OR 1-by-1) ·
567
+ composer: a compact pill that expands for long text + attachments) · review_card (ReviewCard — THE single-proposal review card; compositional body via children + a generic actions footer; header-first with a title/eyebrow, body-first without) · confidence (calibrated high/med/low) ·
568
+ record_fields (RecordFieldsthe editable extracted-record BODY for a ReviewCard, or a standalone live-edit field card) · change_review (ChangeReviewthe BATCH review engine, whole-set OR 1-by-1; compositional `renderItem` body + the `ChangeDiff` before→after helper) ·
532
569
  clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
533
570
  the agent's quick-reply surface) · sources (provenance chips, per-kind glyphs) · spec_list (SpecList — the exact
534
- labeled-value breakdown, the panel of an answer) · match_row (MatchRowa two-sided AI pairing) ·
571
+ labeled-value breakdown, the panel of an answer) · match_sides (MatchSidesthe two-sided pairing BODY for a body-first ReviewCard reconciliation; composable side slots + the MatchSideText default) ·
535
572
  finding (Finding — one ranked briefing/audit insight) · discrepancy (Discrepancy — a field that
536
573
  disagrees across sources) · triage_row (TriageRow — an item the agent classified + routed) ·
537
574
  scored_option (ScoredOption — an AI-ranked candidate) ·
@@ -4,7 +4,7 @@ import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
5
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
6
6
  import { Composer } from "@lotics/ui/composer";
7
- import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
7
+ import { ChangeReview, ChangeDiff, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
8
8
 
9
9
  // ─────────────────────────────────────────────────────────────────────────────
10
10
  // Template · AI assistant — a CHAT workflow, a different shape from the design
@@ -14,17 +14,24 @@ import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from
14
14
  // copilot proposing changes to a record, never committing on its own. All mock.
15
15
  // ─────────────────────────────────────────────────────────────────────────────
16
16
 
17
+ /** One field's before→after edit — the assistant's proposals, reviewed 1-by-1. */
18
+ interface EditChange {
19
+ label: string;
20
+ before?: string;
21
+ after: string;
22
+ }
23
+
17
24
  interface ChatMsg {
18
25
  id: string;
19
26
  role: "user" | "agent";
20
27
  text?: string;
21
28
  /** An agent turn may carry suggested edits to review 1-by-1. */
22
- changes?: ChangeReviewItem[];
29
+ changes?: EditChange[];
23
30
  applied?: boolean;
24
31
  typing?: boolean;
25
32
  }
26
33
 
27
- const SUGGESTED: Omit<ChangeReviewItem, "status">[] = [
34
+ const SUGGESTED: EditChange[] = [
28
35
  { label: "Company", before: "acme packaging co", after: "Acme Packaging Co." },
29
36
  { label: "Phone", before: "0901234567", after: "+84 90 123 4567" },
30
37
  { label: "Email", before: "SALES@ACME.VN", after: "sales@acme.vn" },
@@ -109,7 +116,21 @@ export function TplAssistant() {
109
116
  {m.text ? <Text size="sm">{m.text}</Text> : null}
110
117
  {m.changes ? (
111
118
  <ChangeReview
112
- changes={m.changes.map((c, i) => ({ ...c, status: decisions[`${m.id}:${i}`] ?? "pending" }))}
119
+ items={m.changes}
120
+ getKey={(c, i) => `${m.id}:${i}`}
121
+ statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
122
+ renderItem={(c) => (
123
+ <>
124
+ <Text size="xs" color="muted" weight="medium">{c.label}</Text>
125
+ <ChangeDiff before={c.before} after={c.after} />
126
+ </>
127
+ )}
128
+ renderSummary={(c, kept) => (
129
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
130
+ <Text size="sm" weight="medium" numberOfLines={1}>{c.label}</Text>
131
+ <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{kept ? c.after : (c.before ?? c.after)}</Text>
132
+ </View>
133
+ )}
113
134
  status={m.applied ? "applied" : "open"}
114
135
  applyLabel="Apply accepted"
115
136
  onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
@@ -24,7 +24,7 @@ const MOCK_PHOTO: DisplayFile = {
24
24
  // ─────────────────────────────────────────────────────────────────────────────
25
25
  // Template · AI design canvas (the carton "dieline" app) — the FLAGSHIP shape:
26
26
  // the page IS the design. The dieline fills the centre, a FLOATING composer sits
27
- // at the bottom, and a pinned PARAMS PANEL (the RecordReview field-card, here in
27
+ // at the bottom, and a pinned PARAMS PANEL (a RecordFields field-card, here in
28
28
  // live-edit mode) floats at the centre-right. Empty → drop a photo in the
29
29
  // composer → the centre processes while the composer MORPHS into a progress pill
30
30
  // (AgentProgress) → the dieline reveals, the params panel appears, the composer
@@ -228,7 +228,7 @@ export function TplDieline() {
228
228
  );
229
229
  }
230
230
 
231
- // The pinned right-rail — the RecordReview field-card in live-edit mode: each
231
+ // The pinned right-rail — a RecordFields field-card in live-edit mode: each
232
232
  // parameter is click-to-edit and re-flows the design on save; one primary
233
233
  // Download. Floats over the canvas, so it carries the composer's lift.
234
234
  const PARAMS: { key: keyof Dims; label: string }[] = [
@@ -6,7 +6,8 @@ import { Button } from "@lotics/ui/button";
6
6
  import { Card, CardBody, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
7
  import { FileDropzone } from "@lotics/ui/file_dropzone";
8
8
  import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
9
- import { RecordReview, type RecordField } from "@lotics/ui/record_review";
9
+ import { ReviewCard } from "@lotics/ui/review_card";
10
+ import { RecordFields, type RecordField } from "@lotics/ui/record_fields";
10
11
  import { Sources } from "@lotics/ui/sources";
11
12
  import { CompletionState } from "@lotics/ui/completion_state";
12
13
  import { type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -14,8 +15,8 @@ import { type ConfidenceLevel } from "@lotics/ui/confidence";
14
15
  // ─────────────────────────────────────────────────────────────────────────────
15
16
  // Template · AI extract & confirm — read an image, then review each extracted
16
17
  // RECORD before it's added to a table. Drop a photo → the agent extracts the
17
- // lines (AgentRun) → each lands as a `RecordReview` card whose fields are
18
- // click-to-edit; Confirm collapses it to a tidy checklist row (Undo to re-open),
18
+ // lines (AgentRun) → each lands as a `ReviewCard` wrapping a `RecordFields` body
19
+ // whose fields are click-to-edit; Confirm collapses it to a tidy checklist row (Undo to re-open),
19
20
  // Remove skips it → Save all writes the confirmed lines at once. A one-shot
20
21
  // capture pass: nothing is stored until the human has reviewed it. All mock.
21
22
  // ─────────────────────────────────────────────────────────────────────────────
@@ -145,17 +146,24 @@ export function TplExtract() {
145
146
  <Sources label="Extracted from" onOpen={() => {}} sources={[{ id: "doc", label: "packing-list.jpg", kind: "document", detail: "1 photo" }]} />
146
147
  <View style={{ gap: 10 }}>
147
148
  {RECORDS.map((r) => (
148
- <RecordReview
149
+ <ReviewCard
149
150
  key={r.id}
150
151
  title={r.title}
151
152
  confidence={r.confidence}
152
- status={statuses[r.id] ?? "pending"}
153
- fields={r.fields.map((f, i) => ({ ...f, value: values[r.id][i] }))}
154
- onEditField={(i, value) => editField(r.id, i, value)}
155
- onConfirm={() => setStatus(r.id, "confirmed")}
156
- onRemove={() => setStatus(r.id, "removed")}
153
+ status={statuses[r.id] === "confirmed" ? "accepted" : statuses[r.id] === "removed" ? "dismissed" : "open"}
154
+ acceptedLabel="Confirmed"
155
+ dismissedLabel="Removed"
156
+ actions={[
157
+ { label: "Remove", onPress: () => setStatus(r.id, "removed"), kind: "muted" },
158
+ { label: "Confirm", onPress: () => setStatus(r.id, "confirmed"), kind: "secondary" },
159
+ ]}
157
160
  onUndo={() => setStatus(r.id, undefined)}
158
- />
161
+ >
162
+ <RecordFields
163
+ fields={r.fields.map((f, i) => ({ ...f, value: values[r.id][i] }))}
164
+ onEditField={(i, value) => editField(r.id, i, value)}
165
+ />
166
+ </ReviewCard>
159
167
  ))}
160
168
  </View>
161
169
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -9,23 +9,29 @@ import { Composer } from "@lotics/ui/composer";
9
9
  import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
10
10
  import { SpecList, type SpecRow } from "@lotics/ui/spec_list";
11
11
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
12
+ import { Badge } from "@lotics/ui/badge";
12
13
  import { Sources, type SourceRef } from "@lotics/ui/sources";
13
14
 
14
15
  // ─────────────────────────────────────────────────────────────────────────────
15
- // Template · Answer desk — converse on the LEFT, a pinned structured ANSWER on
16
- // the RIGHT. The AI's verdict isn't prose buried in a chat log: it's a panel of
17
- // scannable components a code, the exact duty breakdown (SpecList), the
18
- // policies that apply, the sources it rested on. You ask, you watch the agent
19
- // work (AgentRun), the panel pins; a follow-up refines the SAME panel. Same
20
- // shape for any look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a
21
- // spec/compliance desk. The structured answer is the product; chat just steers.
16
+ // Template · Answer desk — describe the goods on the LEFT, the agent RANKS the
17
+ // matching codes (nearest matches, the top one Recommended), you pick one and its
18
+ // structured ANSWER pins on the RIGHT. The flow is input → matches → pick, not a
19
+ // single confident verdict: classification is ambiguous, so the alternatives are
20
+ // first-class (the prep/pack push the goods to a different code with a different
21
+ // duty). The answer is a panel of scannable components — the code, the exact duty
22
+ // breakdown (SpecList), the policies, the sources. Refine the description ("what
23
+ // if it's pre-cooked?") and the ranking updates. Same shape for any
24
+ // look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a spec/compliance desk.
22
25
  // ─────────────────────────────────────────────────────────────────────────────
23
26
 
24
27
  type Tone = "warning" | "info" | "success";
25
28
  interface Policy { tone: Tone; title: string; text: string }
26
- interface Verdict {
29
+ interface Candidate {
27
30
  id: string;
28
- query: string;
31
+ /** Short heading for the ranked list on the left. */
32
+ label: string;
33
+ /** The agent's top pick — wears a "Recommended" badge, selected first. */
34
+ recommended?: boolean;
29
35
  code: string;
30
36
  heading: string;
31
37
  confidence: ConfidenceLevel;
@@ -37,10 +43,16 @@ interface Verdict {
37
43
  sources: SourceRef[];
38
44
  }
39
45
 
40
- const VERDICTS: Verdict[] = [
46
+ // One query, several plausible HS codes — the agent RANKS them; the declarant
47
+ // picks. The prep and the pack push the goods to a different code with a
48
+ // different duty, which is exactly why a single confident verdict is wrong.
49
+ const QUERY = "Black tiger shrimp, headless shell-on, IQF frozen, 1 kg retail packs";
50
+
51
+ const CANDIDATES: Candidate[] = [
41
52
  {
42
- id: "v-shrimp",
43
- query: "Black tiger shrimp, headless shell-on, IQF frozen, 1 kg retail packs",
53
+ id: "c1",
54
+ label: "Frozen shrimp · retail packs 1 kg",
55
+ recommended: true,
44
56
  code: "0306.17.10",
45
57
  heading: "Frozen shrimp & prawns of the family Penaeidae — not in airtight containers",
46
58
  confidence: "high",
@@ -69,54 +81,57 @@ const VERDICTS: Verdict[] = [
69
81
  ],
70
82
  },
71
83
  {
72
- id: "v-shirt",
73
- query: "Men's T-shirts, 100% cotton, knit, short sleeve",
74
- code: "6109.10.00",
75
- heading: "T-shirts, singlets & other vests, knitted or crochetedof cotton",
76
- confidence: "high",
77
- basis: "Knitted construction of cotton → heading 6109; subheading .10 for cotton (not man-made fibres).",
84
+ id: "c2",
85
+ label: "Frozen shrimp · other (bulk / catering)",
86
+ code: "0306.17.90",
87
+ heading: "Frozen shrimp & prawns of the family Penaeidaeother",
88
+ confidence: "medium",
89
+ basis:
90
+ "Same heading 0306.17, but the residual national line .90 covers packs ABOVE 1 kg / bulk catering. Right only if the ≤ 1 kg retail-pack evidence is set aside — the duty is identical, the line is not.",
78
91
  duty: [
79
- { label: "Import duty (MFN)", value: "20%" },
80
- { label: "ATIGA (Form D)", value: "0%" },
92
+ { label: "Import duty (MFN)", value: "0%" },
81
93
  { label: "VAT", value: "8%" },
94
+ { label: "Anti-dumping", value: "0%" },
82
95
  ],
83
- dutyTotal: { label: "Effective tax on CIF", value: "8–28%" },
96
+ dutyTotal: { label: "Effective tax on CIF", value: "8%" },
84
97
  specs: [
85
- { label: "Chapter", value: "61Knitted apparel" },
86
- { label: "Construction", value: "Knit" },
87
- { label: "Material", value: "100% cotton" },
98
+ { label: "Chapter", value: "03Fish & crustaceans" },
99
+ { label: "Form", value: "Frozen (IQF)" },
100
+ { label: "Net pack", value: "> 1 kg / bulk" },
88
101
  ],
89
102
  policies: [
90
- { tone: "info", title: "Origin proof is decisive", text: "Without a Form D the MFN 20% applies; with it the line drops to 0% a 20-point swing on CIF." },
103
+ { tone: "warning", title: "Health certificate required", text: "Aquatic-product imports need a health certificate from the competent authority of the exporting country before clearance." },
91
104
  ],
92
105
  sources: [
93
- { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 61" },
94
- { id: "s2", label: "ATIGA tariff", kind: "knowledge" },
106
+ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 03" },
107
+ { id: "s2", label: "Heading 0306.17", kind: "knowledge" },
95
108
  ],
96
109
  },
97
110
  {
98
- id: "v-batt",
99
- query: "Lithium-ion battery packs for e-scooters, 48V",
100
- code: "8507.60.10",
101
- heading: "Lithium-ion accumulators of a kind used as the primary source of power",
102
- confidence: "medium",
103
- basis: "Secondary (rechargeable) lithium-ion cells assembled into a pack → heading 8507.60. The national line turns on end-use; confirm the device class.",
111
+ id: "c3",
112
+ label: "Shrimp, prepared or preserved",
113
+ code: "1605.21.00",
114
+ heading: "Shrimp & prawns, prepared or preserved not in airtight containers",
115
+ confidence: "low",
116
+ basis:
117
+ "Only if the product is COOKED / breaded / marinated → chapter 16 (prepared), heading 1605.21. Raw IQF shrimp is NOT prepared — the wrong line unless processing is confirmed, and it carries a much higher MFN rate.",
104
118
  duty: [
105
- { label: "Import duty (MFN)", value: "5%" },
106
- { label: "VAT", value: "10%" },
119
+ { label: "Import duty (MFN)", value: "20%" },
120
+ { label: "ATIGA (Form D)", value: "0%" },
121
+ { label: "VAT", value: "8%" },
107
122
  ],
108
- dutyTotal: { label: "Effective tax on CIF", value: "15.5%" },
123
+ dutyTotal: { label: "Effective tax on CIF", value: "8–28%" },
109
124
  specs: [
110
- { label: "Chapter", value: "85Electrical machinery" },
111
- { label: "Cell chemistry", value: "Li-ion (rechargeable)" },
112
- { label: "Nominal voltage", value: "48 V" },
125
+ { label: "Chapter", value: "16Prepared fish / crustaceans" },
126
+ { label: "Form", value: "Cooked / prepared" },
127
+ { label: "Presentation", value: "Not airtight" },
113
128
  ],
114
129
  policies: [
115
- { tone: "warning", title: "Dangerous-goods handling", text: "Lithium batteries ship under UN 3480 IATA/IMDG packaging and declaration apply; carriers may require a test summary (UN 38.3)." },
130
+ { tone: "info", title: "Processing decides the chapter", text: "Raw frozen sits in Ch. 03; cooked / breaded / marinated moves to Ch. 16. The 20-point MFN gap makes the evidence of processing decisive." },
116
131
  ],
117
132
  sources: [
118
- { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 85" },
119
- { id: "s2", label: "Heading 8507.60", kind: "knowledge" },
133
+ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 16" },
134
+ { id: "s2", label: "Heading 1605.21", kind: "knowledge" },
120
135
  ],
121
136
  },
122
137
  ];
@@ -131,14 +146,14 @@ const SCRIPT: ScriptStep[] = [
131
146
  ];
132
147
 
133
148
  export function TplLookup() {
134
- const [activeId, setActiveId] = useState(VERDICTS[0].id);
149
+ const [activeId, setActiveId] = useState(CANDIDATES.find((c) => c.recommended)?.id ?? CANDIDATES[0].id);
135
150
  const [phase, setPhase] = useState<"running" | "ready">("ready");
136
151
  const [revealed, setRevealed] = useState(SCRIPT.length);
137
152
  const [prompt, setPrompt] = useState("");
138
153
  const [runKey, setRunKey] = useState(0);
139
154
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);
140
155
 
141
- const active = VERDICTS.find((v) => v.id === activeId) ?? VERDICTS[0];
156
+ const active = CANDIDATES.find((c) => c.id === activeId) ?? CANDIDATES[0];
142
157
 
143
158
  useEffect(() => {
144
159
  if (runKey === 0) return;
@@ -159,16 +174,11 @@ export function TplLookup() {
159
174
  }, [runKey]);
160
175
 
161
176
  const ask = (text: string) => {
162
- const hit =
163
- VERDICTS.find((v) => {
164
- const q = text.toLowerCase();
165
- return (
166
- (v.id === "v-shirt" && (q.includes("shirt") || q.includes("cotton") || q.includes("knit"))) ||
167
- (v.id === "v-batt" && (q.includes("batter") || q.includes("lithium") || q.includes("cell"))) ||
168
- (v.id === "v-shrimp" && (q.includes("shrimp") || q.includes("prawn") || q.includes("frozen")))
169
- );
170
- }) ?? VERDICTS[0];
171
- setActiveId(hit.id);
177
+ // Refining the description re-ranks: cooked → prepared (Ch. 16), bulk → the
178
+ // residual frozen line, else the recommended retail-pack code.
179
+ const q = text.toLowerCase();
180
+ const hit = /cook|prepar|breaded|marinat/.test(q) ? "c3" : /bulk|cater/.test(q) ? "c2" : "c1";
181
+ setActiveId(hit);
172
182
  setPrompt("");
173
183
  setRunKey((k) => k + 1);
174
184
  };
@@ -185,35 +195,38 @@ export function TplLookup() {
185
195
  <View style={{ width: 380, backgroundColor: colors.white, borderRightWidth: 1, borderRightColor: colors.zinc[200] }}>
186
196
  <View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 12, gap: 2 }}>
187
197
  <Text size="md" weight="semibold">HS classification</Text>
188
- <Text size="sm" color="muted">Describe the goods — the agent finds the code and explains the duty</Text>
198
+ <Text size="sm" color="muted">Describe the goods — the agent ranks the matching codes; pick one to see its duty</Text>
189
199
  </View>
190
200
  <Divider />
191
201
 
192
202
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
193
203
  {/* current run */}
194
- <AgentRun title={`Classifying ${active.query}`} steps={steps} state={streaming ? "streaming" : "done"} />
204
+ <AgentRun title="Classifying the goods" steps={steps} state={streaming ? "streaming" : "done"} />
195
205
 
196
206
  <View style={{ gap: 8 }}>
197
- <Text size="xs" color="muted" weight="medium">This session</Text>
198
- {VERDICTS.map((v) => (
207
+ <Text size="xs" color="muted" weight="medium">Nearest matches</Text>
208
+ <Text size="xs" color="muted" numberOfLines={2}>{QUERY}</Text>
209
+ {CANDIDATES.map((c) => (
199
210
  <PressableHighlight
200
- key={v.id}
201
- onPress={() => { setActiveId(v.id); setPhase("ready"); setRevealed(SCRIPT.length); }}
202
- accessibilityLabel={`Show ${v.query}`}
211
+ key={c.id}
212
+ onPress={() => { setActiveId(c.id); setPhase("ready"); setRevealed(SCRIPT.length); }}
213
+ accessibilityLabel={`Show ${c.code} — ${c.label}`}
203
214
  style={{
204
215
  padding: 10,
205
216
  borderRadius: 10,
206
217
  borderWidth: 1,
207
- borderColor: v.id === activeId ? colors.zinc[900] : colors.zinc[200],
208
- backgroundColor: v.id === activeId ? colors.zinc[50] : colors.white,
209
- gap: 3,
218
+ borderColor: c.id === activeId ? colors.zinc[900] : colors.zinc[200],
219
+ backgroundColor: c.id === activeId ? colors.zinc[50] : colors.white,
220
+ gap: 4,
210
221
  }}
211
222
  >
212
- <Text size="sm" weight="medium" numberOfLines={1}>{v.query}</Text>
213
223
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
214
- <Text size="xs" color="muted" tabular>{v.code}</Text>
215
- <Confidence level={v.confidence} />
224
+ <Text size="sm" weight="semibold" tabular numberOfLines={1}>{c.code}</Text>
225
+ {c.recommended ? <Badge color="blue" label="Recommended" /> : null}
226
+ <View style={{ flex: 1 }} />
227
+ <Confidence level={c.confidence} />
216
228
  </View>
229
+ <Text size="xs" color="muted" numberOfLines={1}>{c.label}</Text>
217
230
  </PressableHighlight>
218
231
  ))}
219
232
  </View>
@@ -6,7 +6,8 @@ import { Button } from "@lotics/ui/button";
6
6
  import { KPIStrip } from "@lotics/ui/kpi_strip";
7
7
  import { SegmentedControl } from "@lotics/ui/segmented_control";
8
8
  import { EmptyState } from "@lotics/ui/empty_state";
9
- import { MatchRow } from "@lotics/ui/match_row";
9
+ import { ReviewCard, type ReviewAction } from "@lotics/ui/review_card";
10
+ import { MatchSides, MatchSideText } from "@lotics/ui/match_sides";
10
11
  import type { ConfidenceLevel } from "@lotics/ui/confidence";
11
12
 
12
13
  // ─────────────────────────────────────────────────────────────────────────────
@@ -102,19 +103,29 @@ export function TplMatch() {
102
103
  />
103
104
  ) : (
104
105
  <View style={{ gap: 10 }}>
105
- {visible.map((r) => (
106
- <MatchRow
107
- key={r.id}
108
- source={r.source}
109
- match={r.match}
110
- rationale={r.rationale}
111
- confidence={r.confidence}
112
- status={r.status}
113
- onAccept={r.match ? () => set(r.id, "accepted") : undefined}
114
- onReassign={r.alt ? () => reassign(r.id) : undefined}
115
- onDismiss={() => set(r.id, "dismissed")}
116
- />
117
- ))}
106
+ {visible.map((r) => {
107
+ const actions: ReviewAction[] = [
108
+ { label: "Not a match", onPress: () => set(r.id, "dismissed"), kind: "muted" },
109
+ ...(r.alt ? [{ label: r.match ? "Reassign" : "Find match", onPress: () => reassign(r.id), kind: "secondary" as const }] : []),
110
+ ...(r.match ? [{ label: "Accept", onPress: () => set(r.id, "accepted"), kind: "primary" as const }] : []),
111
+ ];
112
+ return (
113
+ <ReviewCard
114
+ key={r.id}
115
+ summary={r.source.title}
116
+ confidence={r.confidence}
117
+ rationale={r.rationale}
118
+ status={r.status}
119
+ acceptedLabel={r.match ? `Matched · ${r.match.title}` : "Matched"}
120
+ actions={actions}
121
+ >
122
+ <MatchSides
123
+ source={<MatchSideText title={r.source.title} detail={r.source.detail} />}
124
+ match={r.match ? <MatchSideText title={r.match.title} detail={r.match.detail} align="right" /> : undefined}
125
+ />
126
+ </ReviewCard>
127
+ );
128
+ })}
118
129
  </View>
119
130
  )}
120
131
  </View>