@lotics/ui 4.4.0 → 4.5.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
@@ -78,9 +78,16 @@ Pick by capability, not by name. (→ the source file for the API.)
78
78
  - **AI surfaces** — `PromptField` (the NL command that triggers agent work — NOT a chat),
79
79
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
80
80
  form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
81
- + `Confidence`, `AssistedField` (a confirmable AI estimate), `ChangeReview` (before→after for an
82
- agent-proposed edit whole-set OR 1-by-1), `Clarify` (the agent asks back a question + quick
83
- replies), `Sources` (provenance chips for AI output). See §AI workflows.
81
+ + `Confidence`, `RecordReview` (an extracted record reviewed + edited before save — fields
82
+ click-to-edit, Confirm collapses it), `ChangeReview` (before→after for an agent-proposed edit
83
+ whole-set OR 1-by-1), `Clarify` (the agent asks back selectable `ChoiceList` options),
84
+ `Sources` (provenance chips for AI output). For STRUCTURED AI output the four review
85
+ surfaces above don't cover: `SpecList` (the "exact breakdown" — a labeled-value spec sheet, the
86
+ panel of an answer), `MatchRow` (a two-sided AI pairing — source ↔ proposed match + confidence),
87
+ `Finding` (one ranked briefing/audit insight — severity + metric + sources + action), `Discrepancy`
88
+ (a field whose value disagrees across sources — pick the truth), `TriageRow` (an incoming item the
89
+ agent classified + routed), `ScoredOption` (an AI-ranked candidate — score + rationale + specs).
90
+ See §AI workflows.
84
91
 
85
92
  ---
86
93
 
@@ -218,7 +225,8 @@ Compose the surfaces as a loop, and reach for the right one by job:
218
225
  - **Command** — `PromptField`, NOT a chat composer. A prompt TRIGGERS agent work (Enter sends);
219
226
  while it runs the field locks and the send spins. The imperative "do this" surface.
220
227
  - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
221
- spine, the running node pulsing violet. Transparent work, NEVER a bare spinner — the
228
+ spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
229
+ work, NEVER a bare spinner — the
222
230
  intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
223
231
  collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
224
232
  morphs into it while running, and reveals again when done.
@@ -227,9 +235,10 @@ Compose the surfaces as a loop, and reach for the right one by job:
227
235
  For a shortlist, stack ranked `Suggestion`s (highest confidence first). `ChangeReview` also has a
228
236
  **1-by-1** mode — pass `onAcceptItem`/`onRejectItem` and each suggested change gets its own ✓/✕,
229
237
  Apply committing only the accepted set (the chat copilot). Nothing auto-applies.
230
- - **Confirmable estimates** — `AssistedField`: an AI-prefilled value reads as a TINTED ESTIMATE
231
- until a person confirms or overrides it provenance stays visible. Stack several with one
232
- form-level "Confirm all".
238
+ - **Review an extracted record** — `RecordReview`: one record the agent pulled from a document, its
239
+ fields click-to-edit (`InlineTextInput`), with a confidence + Confirm / Remove; Confirm collapses
240
+ it to a checklist row (Undo to re-open). Stack several under a "Save all" to capture a whole
241
+ document → table (see `tpl_extract`). Nothing writes until the human confirms + saves.
233
242
  - **Ask back** — `Clarify`: when the agent is unsure, it asks a question with quick-reply options
234
243
  and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run.
235
244
  - **Provenance** — `Sources`: openable chips saying where the output came FROM (records, a
@@ -240,22 +249,68 @@ Compose the surfaces as a loop, and reach for the right one by job:
240
249
  "make it a bit less", but it's a run LOG, not a conversation transcript — that's why it's not a
241
250
  chat.)
242
251
 
243
- Four shapes prove the range — pick by whether the work is a living artifact, a conversation, a
244
- single pass, or generated prose:
245
- - **Canvas** (`tpl_dieline`) — the page IS the design, a FLOATING composer at the bottom that
246
- morphs into `AgentProgress` while running; the centre goes empty processing result, and a
247
- prompt re-flows the design in place. For a design/document the user shapes over time.
252
+ Ten shapes prove the range. The first four PRODUCE — pick by whether the work is a living artifact,
253
+ a conversation, a single pass, or generated prose:
254
+ - **Canvas** (`tpl_dieline`) — the page IS the design on a pannable/zoomable surface: it stays
255
+ CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom
256
+ morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (the `RecordReview` field-card
257
+ in live-edit mode, carrying the single Download; minimizes to a pill) floats centre-right. Panel, zoom pill and composer
258
+ float ON TOP — they never shift the design. (Floating layers use a `pointerEvents:"none"` wrapper
259
+ with the interactive child set `"auto"`; RN-Web ignores `"box-none"` in style, so a full-width
260
+ wrapper would otherwise eat clicks on the canvas behind it.) Change the design by prompt ("5 mm
261
+ taller") OR by editing a param directly — either re-flows it in place. For a design/document the
262
+ user shapes over time.
248
263
  - **Chat** (`tpl_assistant`) — a conversation where the agent's answer carries suggested edits the
249
264
  user approves/rejects **1-by-1** (`ChangeReview` per-item), applying only the accepted set. An AI
250
265
  copilot that proposes changes to a record and never commits on its own.
251
- - **One-shot** (`tpl_extract`) — document streamconfirm each extracted field submit. A
252
- data-capture pass with no iteration.
266
+ - **One-shot** (`tpl_extract`, `RecordReview`) — read an imagethe agent extracts the records
267
+ review each (`RecordReview`: fields click-to-edit, Confirm collapses it to a checklist row) →
268
+ Save all to the table. A data-capture pass; nothing stored until confirmed.
253
269
  - **Draft** (`tpl_draft`) — the agent writes a first draft from context; the OUTPUT itself streams
254
270
  (the text fills in live), then becomes an editable surface the human refines before sending.
255
271
  Tone/length steer it. For generated prose — a reply, a quote, a listing.
256
272
 
257
- The AI accent is **violet**distinct from the purpose accents (blue=pipeline, emerald=money), it
258
- marks what came from the agent.
273
+ The next six output STRUCTURE the first four can't when the answer is a panel of facts, a queue of
274
+ decisions, or a ranked set, don't cram it into chat prose. Each pairs a template with a primitive:
275
+ - **Answer desk** (`tpl_lookup`, `SpecList`) — converse on the LEFT, a pinned STRUCTURED answer on
276
+ the RIGHT (a verdict header + an exact breakdown + the policies + sources); a follow-up refines the
277
+ same panel. For look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a spec/compliance desk. The
278
+ structured answer is the product; chat just steers. (NOT a chat with the answer buried in a bubble.)
279
+ - **Match** (`tpl_match`, `MatchRow`) — the agent proposes PAIRINGS with its reasoning; each row is
280
+ two-sided (source ↔ proposed counterpart + confidence), accept / reassign / reject, high-confidence
281
+ batch-accept. The AI reconciliation / dedup / correlation queue. (The deterministic cousin
282
+ `tpl_reconcile` has no agent reasoning — that's the tell for which to reach for.)
283
+ - **Briefing** (`tpl_briefing`, `Finding`) — a generated narrative over the data + a RANKED list of
284
+ what needs attention, each finding carrying a severity, a metric, its sources, and one action. The
285
+ leadership digest / anomaly scan — the agent's COMMENTARY, not a raw dashboard.
286
+ - **Cross-check** (`tpl_crosscheck`, `Discrepancy`) — the agent compares the documents of one
287
+ transaction and surfaces every field that DISAGREES; resolve each to a source of truth or flag it.
288
+ The audit / 3-way-match desk. Symmetric (N sources), unlike a before→after edit.
289
+ - **Triage** (`tpl_triage`, `TriageRow`) — an inbox the agent classified + routed; accept the call,
290
+ override, or dismiss, high-confidence in bulk. Leads, tickets, documents, emails.
291
+ - **Compare** (`tpl_compare`, `ScoredOption`) — the agent SCORES and ranks options with its rationale
292
+ and the key specs; the human picks one. Quotes, carriers, suppliers, plans.
293
+
294
+ The AI vocabulary has **no purple accent and no gimmick glyphs** (no sparkles) — but it is NOT
295
+ monochrome: **colour is used where it carries meaning, not for decoration.** What the violet sparkle
296
+ used to carry now reads structurally — **provenance** is an uppercase microlabel naming the artifact
297
+ (`PROPOSED` · `MATCH` · `MISMATCH` · `SUGGESTED EDIT` · `QUESTION`), and the agent's **reasoning** is
298
+ a left-ruled margin note (a hairline rule + muted text), quoted apart from the facts and the human's
299
+ controls — while **status/severity/diffs use functional colour** the way the rest of the kit does:
300
+ - **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
301
+ - **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
302
+ emerald on-track (+ stacking order, most severe first).
303
+ - **AgentRun** — `Timeline`-style tinted-disc nodes that fade in: a running step spins **blue**, a
304
+ finished step is an **emerald check**, a failure a **red** alert; the header reads `Working` (blue
305
+ spinner) / `Done` (emerald check) / `Stopped` (red).
306
+ - **ChangeReview diffs** — the old value struck in **red**, the new value in **green**.
307
+ - **Discrepancy** — the agent's pick gets a **blue** highlight; `Mismatch` reads amber, `Resolved`
308
+ emerald.
309
+
310
+ Card chrome (borders, microlabels) stays neutral — colour marks the *state*, never the container.
311
+ The **composer keeps its icons**: `PromptField` is a single-row pill with circular attach + send
312
+ `Button`s; `AgentProgress` is the `WaveAvatar` pill. "No icons" was only ever about the review
313
+ surfaces' sparkles/severity glyphs, not functional affordances.
259
314
 
260
315
  ---
261
316
 
@@ -274,10 +329,14 @@ marks what came from the agent.
274
329
  content, sections separated by ONE `Divider`. Do NOT wrap each section in its own `CardHeader`
275
330
  (its auto-divider double-stacks hairlines around every title). A card-less full page uses
276
331
  `SectionHeading` + `SectionHeadingTitle`. NEVER a bare eyebrow standing in for a section title.
277
- · **Eyebrow** (`<Text size="xs" color="muted" transform="uppercase">`, WEIGHTLESS): ONLY a
278
- column header or a truly minor one-line label not a section that owns real content. Don't add
279
- `weight`. (A COLORED status word like "Blocked" `weight="semibold"` + `color="danger"` is a
280
- different element, the verdict word, and keeps its weight.)
332
+ · **Eyebrow / label** (`<Text size="xs" color="muted" weight="medium">` **sentence case, NEVER
333
+ `transform="uppercase"`**): a small quiet label above or beside content an artifact tag
334
+ ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
335
+ label. **All-caps is banned it reads as shouting and the redundant uppercasing of every little
336
+ label is the #1 thing that makes a surface feel templated.** Sentence case + medium weight, full
337
+ stop. (A COLORED status word — a verdict like "Mismatch" / "Resolved" — keeps the same xs/medium
338
+ shape with a status `color`, still not uppercase.) This applies to every hand-written label; don't
339
+ reach for a wrapper component either, just write the `Text`.
281
340
  · **Gate header**: a `Dialog` uses `DialogHeaderTitle`; a popover form uses `Text size="sm"
282
341
  weight="semibold"` + an optional `xs muted` subtitle.
283
342
  - **Time-constrained data gets a period filter** in the header band — `DateRangeFilterField`, never
@@ -401,12 +460,18 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
401
460
  `tpl_timeline` (audit feed).
402
461
  - **Planning & time** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
403
462
  - **Administration** — `tpl_settings`.
404
- - **AI workflows** — `tpl_dieline` (the design CANVAS: photo → stream → the dieline reveals at
405
- centre, the floating composer morphing into `AgentProgress`; prompt to iterate) · `tpl_assistant`
406
- (a chat copilot: the agent's answer carries suggested edits approved/rejected 1-by-1) ·
407
- `tpl_extract` (one-shot extract-&-confirm: document stream confirm each field → submit) ·
408
- `tpl_draft` (draft generation: contextthe reply text streams into an editable surface
409
- refinesend; tone/length steer it).
463
+ - **AI workflows** — *produce*: `tpl_dieline` (the design CANVAS: photo → stream → the dieline
464
+ reveals CENTRED on a pannable/zoomable surface, the floating composer morphing into `AgentProgress`,
465
+ a pinned live-edit params panel centre-right; prompt OR edit a param to iterate) ·
466
+ `tpl_assistant` (a chat copilot: the agent's answer carries suggested edits approved/rejected
467
+ 1-by-1) · `tpl_extract` (read an imageextract records review/edit each (`RecordReview`)
468
+ Save all) · `tpl_draft` (draft generation: context the reply text streams into an editable surface
469
+ → refine → send; tone/length steer it). *Structure*: `tpl_lookup` (the ANSWER desk: converse left,
470
+ a pinned `SpecList` verdict panel right; a follow-up refines it) · `tpl_match` (AI reconciliation:
471
+ `MatchRow` pairings with reasoning, accept/reassign/reject) · `tpl_briefing` (a generated narrative
472
+ + a ranked `Finding` attention list) · `tpl_crosscheck` (the audit desk: `Discrepancy` cards where
473
+ the documents disagree) · `tpl_triage` (an inbox of `TriageRow`s the agent classified + routed) ·
474
+ `tpl_compare` (ranked `ScoredOption` candidates the agent scored).
410
475
 
411
476
  ---
412
477
 
@@ -437,7 +502,12 @@ spacer · stack · section_card · page_header · page_content · calendar (cale
437
502
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
438
503
  expandable form — a composer's working state) · prompt_field (the NL command surface + optional
439
504
  `onAttach`, not a chat) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
440
- assisted_field (confirmable AI estimate) · change_review (before→after — whole-set OR 1-by-1) ·
441
- clarify (the agent asks back) · sources (provenance chips) ·
505
+ record_review (RecordReview an extracted record reviewed + edited before save) · change_review (before→after — whole-set OR 1-by-1) ·
506
+ clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList selectable answer options,
507
+ the agent's quick-reply surface) · sources (provenance chips, per-kind glyphs) · spec_list (SpecList — the exact
508
+ labeled-value breakdown, the panel of an answer) · match_row (MatchRow — a two-sided AI pairing) ·
509
+ finding (Finding — one ranked briefing/audit insight) · discrepancy (Discrepancy — a field that
510
+ disagrees across sources) · triage_row (TriageRow — an item the agent classified + routed) ·
511
+ scored_option (ScoredOption — an AI-ranked candidate) ·
442
512
  format_money · format_date · colors (solid · tint · ramp · ColorName ·
443
513
  isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
@@ -1,8 +1,7 @@
1
1
  import { useRef, useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
- import { colors, solid, tint } from "@lotics/ui/colors";
3
+ import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
- import { Icon } from "@lotics/ui/icon";
6
5
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
7
6
  import { PromptField } from "@lotics/ui/prompt_field";
8
7
  import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
@@ -80,8 +79,8 @@ export function TplAssistant() {
80
79
  return (
81
80
  <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
82
81
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
83
- <View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12) }}>
84
- <Icon name="sparkles" size={13} color={solid("violet")} />
82
+ <View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
83
+ <View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
85
84
  </View>
86
85
  <Text size="sm" weight="semibold">Record assistant</Text>
87
86
  </View>
@@ -100,8 +99,8 @@ export function TplAssistant() {
100
99
  const active = m.changes != null && !m.applied;
101
100
  return (
102
101
  <View key={m.id} style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
103
- <View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12), marginTop: 1 }}>
104
- <Icon name="sparkles" size={14} color={solid("violet")} />
102
+ <View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100], marginTop: 1 }}>
103
+ <View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
105
104
  </View>
106
105
  <View style={{ flex: 1, gap: 10 }}>
107
106
  {m.typing ? (
@@ -115,6 +114,7 @@ export function TplAssistant() {
115
114
  applyLabel="Apply accepted"
116
115
  onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
117
116
  onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
117
+ onUndoItem={active ? (i) => setDecisions((d) => { const n = { ...d }; delete n[`${m.id}:${i}`]; return n; }) : undefined}
118
118
  onApply={active ? () => applyMsg(m.id) : undefined}
119
119
  onDiscard={active ? () => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x))) : undefined}
120
120
  />
@@ -0,0 +1,121 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
6
+ import { SegmentedControl } from "@lotics/ui/segmented_control";
7
+ import { Sources } from "@lotics/ui/sources";
8
+ import { Finding, type FindingSeverity } from "@lotics/ui/finding";
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Template · Briefing — the agent reads the period's data and writes a short
12
+ // narrative, then a RANKED list of what needs attention. Each finding carries a
13
+ // severity, a headline metric, the sources it rests on, and the one action it
14
+ // suggests. Unlike a dashboard (raw charts), this is the agent's COMMENTARY:
15
+ // what changed, what's at risk, what to do. The leadership digest / daily ops
16
+ // brief / anomaly scan. The narrative explains; the findings are the worklist.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ interface F {
20
+ severity: FindingSeverity;
21
+ title: string;
22
+ detail: string;
23
+ metric: string;
24
+ metricCaption: string;
25
+ action: string;
26
+ sources: { id: string; label: string; kind: "record" | "table" | "document" }[];
27
+ }
28
+
29
+ const NARRATIVE: Record<"today" | "week", string> = {
30
+ today:
31
+ "Volume held steady at 41 active shipments. Three reefer containers to Hamburg are inside the demurrage window and need a pickup booked today. Cash collection is lagging — five invoices crossed 30 days overdue. On-time delivery improved on the back of the new Cát Lái slotting.",
32
+ week:
33
+ "A solid week: 268 shipments moved, on-time delivery up 6 points after the Cát Lái slotting change. Two ocean lanes slipped below the 12% margin target on bunker surcharges. Receivables remain the soft spot — ₫120M is now past 30 days, concentrated in two accounts.",
34
+ };
35
+
36
+ const FINDINGS: Record<"today" | "week", F[]> = {
37
+ today: [
38
+ { severity: "critical", title: "3 reefer containers at demurrage risk", detail: "Hamburg shipments HBL-4471/4472/4480 hit free-time expiry at 18:00 today; no pickup is booked.", metric: "₫48M", metricCaption: "exposure", action: "Open the shipments", sources: [{ id: "a1", label: "SHIP-4471", kind: "record" }, { id: "a2", label: "Demurrage tracker", kind: "table" }] },
39
+ { severity: "warning", title: "5 invoices overdue past 30 days", detail: "Two accounts — Crestline and Meridian — make up most of the balance. No promise-to-pay logged this week.", metric: "₫120M", metricCaption: "overdue", action: "Open receivables", sources: [{ id: "a3", label: "Aged receivables", kind: "table" }] },
40
+ { severity: "warning", title: "Margin on the Hamburg lane below target", detail: "Bunker surcharge rose 9%; the lane is at 9.8% gross vs the 12% floor.", metric: "−2.2 pts", metricCaption: "vs target", action: "Review lane pricing", sources: [{ id: "a4", label: "Lane P&L", kind: "table" }] },
41
+ { severity: "positive", title: "On-time delivery improving", detail: "The Cát Lái slotting change lifted on-time pickups for the third day running.", metric: "+6 pts", metricCaption: "3-day", action: "See the trend", sources: [{ id: "a5", label: "OTD report", kind: "document" }] },
42
+ ],
43
+ week: [
44
+ { severity: "critical", title: "Receivables concentration risk", detail: "₫120M past 30 days sits in two accounts; one is also near its credit limit.", metric: "2", metricCaption: "accounts", action: "Open receivables", sources: [{ id: "b1", label: "Aged receivables", kind: "table" }] },
45
+ { severity: "warning", title: "Two ocean lanes below margin target", detail: "Hamburg and Rotterdam slipped under 12% on bunker surcharges sustained all week.", metric: "2", metricCaption: "lanes", action: "Review lane pricing", sources: [{ id: "b2", label: "Lane P&L", kind: "table" }] },
46
+ { severity: "positive", title: "On-time delivery up 6 points", detail: "The slotting change held all week — the strongest OTD in two months.", metric: "+6 pts", metricCaption: "this week", action: "See the trend", sources: [{ id: "b3", label: "OTD report", kind: "document" }] },
47
+ { severity: "info", title: "RFQ volume rising", detail: "12 new quote requests this week, ahead of the trailing average — capacity planning should get ahead of it.", metric: "12", metricCaption: "RFQs", action: "Open the pipeline", sources: [{ id: "b4", label: "Quote pipeline", kind: "table" }] },
48
+ ],
49
+ };
50
+
51
+ export function TplBriefing() {
52
+ const [period, setPeriod] = useState<"today" | "week">("today");
53
+ const findings = FINDINGS[period];
54
+ const attention = findings.filter((f) => f.severity === "critical" || f.severity === "warning").length;
55
+
56
+ return (
57
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
58
+ <View style={{ width: "100%", maxWidth: 860, alignSelf: "center", gap: 16 }}>
59
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
60
+ <View style={{ flex: 1, gap: 2 }}>
61
+ <Text size="xl" weight="semibold">Operations briefing</Text>
62
+ <Text size="sm" color="muted">What the agent sees in the numbers — and what needs a decision</Text>
63
+ </View>
64
+ <SegmentedControl
65
+ accessibilityLabel="Period"
66
+ options={[
67
+ { label: "Today", value: "today" },
68
+ { label: "This week", value: "week" },
69
+ ]}
70
+ value={period}
71
+ onValueChange={setPeriod}
72
+ />
73
+ </View>
74
+
75
+ {/* the generated narrative */}
76
+ <View style={{ borderWidth: 1, borderColor: colors.zinc[200], backgroundColor: colors.white, borderRadius: 14, padding: 18, gap: 12 }}>
77
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
78
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Summary</Text>
79
+ <Text size="xs" color="muted">{period === "today" ? "as of 14:20" : "week to date"}</Text>
80
+ </View>
81
+ <Text size="md" style={{ lineHeight: 22 }}>{NARRATIVE[period]}</Text>
82
+ <Sources
83
+ label="Read from"
84
+ onOpen={() => {}}
85
+ sources={[
86
+ { id: "n1", label: "Shipments", kind: "table" },
87
+ { id: "n2", label: "Aged receivables", kind: "table" },
88
+ { id: "n3", label: "Lane P&L", kind: "table" },
89
+ ]}
90
+ />
91
+ </View>
92
+
93
+ <KPIStrip
94
+ items={[
95
+ { label: "Active shipments", value: period === "today" ? 41 : 268, format: "number", trend: period === "today" ? 0 : 4 },
96
+ { label: "On-time delivery", value: "94%", trend: 6 },
97
+ { label: "Overdue receivables", value: 120_000_000, format: "currency", compact: true, tone: "danger" },
98
+ { label: "Needs attention", value: attention, format: "number", tone: attention > 0 ? "warning" : "default" },
99
+ ]}
100
+ />
101
+
102
+ <View style={{ gap: 10 }}>
103
+ <Text size="sm" weight="semibold">Needs attention</Text>
104
+ {findings.map((f, i) => (
105
+ <Finding
106
+ key={i}
107
+ severity={f.severity}
108
+ title={f.title}
109
+ detail={f.detail}
110
+ metric={f.metric}
111
+ metricCaption={f.metricCaption}
112
+ sources={f.sources}
113
+ onOpenSource={() => {}}
114
+ action={{ label: f.action, onPress: () => {} }}
115
+ />
116
+ ))}
117
+ </View>
118
+ </View>
119
+ </ScrollView>
120
+ );
121
+ }
@@ -0,0 +1,133 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
6
+ import { ScoredOption } from "@lotics/ui/scored_option";
7
+ import type { SpecRow } from "@lotics/ui/spec_list";
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // Template · Compare — the agent SCORES and ranks the options against the
11
+ // criteria, shows its reasoning and the key specs, and the human picks one. The
12
+ // agent does the legwork (gather, normalise, score); the choice stays the
13
+ // human's. Quotes, carriers, suppliers, plans, routes. The recommended option
14
+ // wears a RECOMMENDED label; selecting one settles the shortlist to the decision.
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ interface O {
18
+ id: string;
19
+ rank: number;
20
+ title: string;
21
+ subtitle: string;
22
+ score: number;
23
+ rationale: string;
24
+ specs: SpecRow[];
25
+ recommended?: boolean;
26
+ }
27
+
28
+ const OPTIONS: O[] = [
29
+ {
30
+ id: "o1",
31
+ rank: 1,
32
+ title: "Maersk — Cát Lái → Hamburg",
33
+ subtitle: "Direct, weekly service · reefer guaranteed",
34
+ score: 0.92,
35
+ rationale: "Best balance: only 4% above the cheapest, but the most reliable schedule and guaranteed reefer plugs — lowest demurrage risk for time-sensitive cargo.",
36
+ recommended: true,
37
+ specs: [
38
+ { label: "All-in rate", value: "$2,840" },
39
+ { label: "Transit", value: "28 days" },
40
+ { label: "Schedule reliability", value: "94%" },
41
+ { label: "Free time", value: "14 days" },
42
+ ],
43
+ },
44
+ {
45
+ id: "o2",
46
+ rank: 2,
47
+ title: "CMA CGM — Cát Lái → Hamburg",
48
+ subtitle: "1 transshipment at Singapore",
49
+ score: 0.78,
50
+ rationale: "Cheapest of the four, but the Singapore transshipment adds three days and a reliability hit — workable for non-urgent cargo.",
51
+ specs: [
52
+ { label: "All-in rate", value: "$2,730" },
53
+ { label: "Transit", value: "31 days" },
54
+ { label: "Schedule reliability", value: "86%" },
55
+ { label: "Free time", value: "10 days" },
56
+ ],
57
+ },
58
+ {
59
+ id: "o3",
60
+ rank: 3,
61
+ title: "Hapag-Lloyd — Cát Lái → Hamburg",
62
+ subtitle: "Direct, premium service",
63
+ score: 0.64,
64
+ rationale: "Fastest transit and strong reliability, but the premium rate is hard to justify unless the customer pays for speed.",
65
+ specs: [
66
+ { label: "All-in rate", value: "$3,210" },
67
+ { label: "Transit", value: "26 days" },
68
+ { label: "Schedule reliability", value: "92%" },
69
+ { label: "Free time", value: "10 days" },
70
+ ],
71
+ },
72
+ {
73
+ id: "o4",
74
+ rank: 4,
75
+ title: "ONE — Cát Lái → Hamburg",
76
+ subtitle: "2 transshipments · no reefer guarantee",
77
+ score: 0.41,
78
+ rationale: "Lowest score: two transshipments and no guaranteed reefer plug make it unsuitable for frozen cargo, despite a mid-range rate.",
79
+ specs: [
80
+ { label: "All-in rate", value: "$2,910" },
81
+ { label: "Transit", value: "34 days" },
82
+ { label: "Schedule reliability", value: "79%" },
83
+ { label: "Free time", value: "7 days" },
84
+ ],
85
+ },
86
+ ];
87
+
88
+ export function TplCompare() {
89
+ const [chosen, setChosen] = useState<string | null>(null);
90
+ const pick = OPTIONS.find((o) => o.id === chosen) ?? null;
91
+
92
+ return (
93
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
94
+ <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
95
+ <View style={{ gap: 2 }}>
96
+ <Text size="xl" weight="semibold">Freight options</Text>
97
+ <Text size="sm" color="muted">Ocean · Cát Lái → Hamburg · 2×40HC reefer · frozen shrimp</Text>
98
+ </View>
99
+
100
+ <View style={{ borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 }}>
101
+ <Text size="sm" color="muted">
102
+ The agent scored four quotes against cost, transit time and schedule reliability, weighted for time-sensitive reefer cargo.
103
+ </Text>
104
+ </View>
105
+
106
+ {pick ? (
107
+ <Callout tone="neutral">
108
+ <CalloutTitle>Chosen · {pick.title}</CalloutTitle>
109
+ <CalloutText>{pick.specs[0].label} {String(pick.specs[0].value)} · {String(pick.specs[1].value)} transit. Booking can proceed on this option.</CalloutText>
110
+ </Callout>
111
+ ) : null}
112
+
113
+ <View style={{ gap: 12 }}>
114
+ {OPTIONS.map((o) => (
115
+ <ScoredOption
116
+ key={o.id}
117
+ rank={o.rank}
118
+ title={o.title}
119
+ subtitle={o.subtitle}
120
+ score={o.score}
121
+ rationale={o.rationale}
122
+ specs={o.specs}
123
+ recommended={o.recommended}
124
+ selected={chosen === o.id}
125
+ selectLabel="Choose this option"
126
+ onSelect={() => setChosen((c) => (c === o.id ? null : o.id))}
127
+ />
128
+ ))}
129
+ </View>
130
+ </View>
131
+ </ScrollView>
132
+ );
133
+ }
@@ -0,0 +1,120 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
6
+ import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
7
+ import { Discrepancy, type DiscrepancyValue } from "@lotics/ui/discrepancy";
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // Template · Cross-check — the agent compares the documents of one transaction
11
+ // and surfaces every field whose value DISAGREES across sources. Each card lays
12
+ // the conflicting values side by side (with where each came from), marks the one
13
+ // the agent believes, and the human resolves to a truth or flags it. The audit /
14
+ // 3-way-match / contract-vs-invoice desk. Symmetric (N sources disagree), unlike
15
+ // a before→after edit. The agreeing fields stay out of the way — only conflicts
16
+ // need a decision.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ interface D {
20
+ id: string;
21
+ field: string;
22
+ values: DiscrepancyValue[];
23
+ note: string;
24
+ }
25
+
26
+ const SEED: D[] = [
27
+ {
28
+ id: "d1",
29
+ field: "Total cartons",
30
+ values: [
31
+ { source: "Shipping instruction", value: "525" },
32
+ { source: "Commercial invoice", value: "520", recommended: true },
33
+ { source: "Packing list", value: "520" },
34
+ ],
35
+ note: "Two of three documents agree at 520; the SI looks stale — it predates the final load.",
36
+ },
37
+ {
38
+ id: "d2",
39
+ field: "Net weight",
40
+ values: [
41
+ { source: "Commercial invoice", value: "11,980 kg" },
42
+ { source: "Packing list", value: "12,050 kg", recommended: true },
43
+ ],
44
+ note: "The packing list carries the measured weight; the CI rounded for the invoice.",
45
+ },
46
+ {
47
+ id: "d3",
48
+ field: "Incoterm",
49
+ values: [
50
+ { source: "Shipping instruction", value: "FOB" },
51
+ { source: "Commercial invoice", value: "CIF", recommended: true },
52
+ ],
53
+ note: "The commercial invoice governs the sale terms — CIF matches the freight prepaid on the bill of lading.",
54
+ },
55
+ {
56
+ id: "d4",
57
+ field: "HS code",
58
+ values: [
59
+ { source: "Commercial invoice", value: "0306.17", recommended: true },
60
+ { source: "Customs draft", value: "0306.16" },
61
+ ],
62
+ note: "0306.16 is cold-water shrimp; the goods are warm-water Penaeidae → 0306.17. The CI is right — fix the draft.",
63
+ },
64
+ ];
65
+
66
+ const CHECKED = 22;
67
+
68
+ export function TplCrosscheck() {
69
+ const [res, setRes] = useState<Record<string, { idx?: number; flagged?: boolean }>>({});
70
+
71
+ const resolvedCount = Object.values(res).filter((r) => r.idx != null || r.flagged).length;
72
+ const openCount = SEED.length - resolvedCount;
73
+
74
+ return (
75
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
76
+ <View style={{ width: "100%", maxWidth: 760, alignSelf: "center", gap: 16 }}>
77
+ <View style={{ gap: 2 }}>
78
+ <Text size="xl" weight="semibold">Document cross-check</Text>
79
+ <Text size="sm" color="muted">Shipment HBL-4471 · Shipping instruction · Commercial invoice · Packing list · Customs draft</Text>
80
+ </View>
81
+
82
+ <KPIStrip
83
+ items={[
84
+ { label: "Fields checked", value: CHECKED, format: "number" },
85
+ { label: "In agreement", value: CHECKED - SEED.length, format: "number" },
86
+ { label: "Disagree", value: openCount, format: "number", tone: openCount > 0 ? "danger" : "default", info: "Fields whose value differs across the documents — each needs a resolution before the set can be filed." },
87
+ { label: "Resolved", value: resolvedCount, format: "number" },
88
+ ]}
89
+ />
90
+
91
+ {openCount > 0 ? (
92
+ <Callout tone="warning">
93
+ <CalloutTitle>{openCount} of {CHECKED} fields disagree across the documents</CalloutTitle>
94
+ <CalloutText>The agent reconciled the other {CHECKED - SEED.length}. Resolve each conflict to a source of truth, or flag it for the desk.</CalloutText>
95
+ </Callout>
96
+ ) : (
97
+ <Callout tone="success">
98
+ <CalloutTitle>All conflicts resolved</CalloutTitle>
99
+ <CalloutText>Every field now has a single source of truth — the document set is ready to file.</CalloutText>
100
+ </Callout>
101
+ )}
102
+
103
+ <View style={{ gap: 10 }}>
104
+ {SEED.map((d) => (
105
+ <Discrepancy
106
+ key={d.id}
107
+ field={d.field}
108
+ values={d.values}
109
+ note={d.note}
110
+ resolvedIndex={res[d.id]?.idx}
111
+ flagged={res[d.id]?.flagged}
112
+ onResolve={(idx) => setRes((p) => ({ ...p, [d.id]: { idx } }))}
113
+ onFlag={() => setRes((p) => ({ ...p, [d.id]: { flagged: true } }))}
114
+ />
115
+ ))}
116
+ </View>
117
+ </View>
118
+ </ScrollView>
119
+ );
120
+ }