@lotics/ui 4.9.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
4
+ import { reviewCardStyle } from "./control_surface";
5
+ import { Text } from "./text";
6
+ import { Icon } from "./icon";
7
+ import { Button } from "./button";
8
+ import { Confidence, type ConfidenceLevel } from "./confidence";
9
+
10
+ export interface ReviewAction {
11
+ label: string;
12
+ onPress: () => void;
13
+ /** Visual weight. Default: the LAST action reads "primary" (the affirmative),
14
+ * the rest "muted". Use "secondary" for a non-destructive middle action
15
+ * (Edit / Reassign). */
16
+ kind?: "primary" | "secondary" | "muted";
17
+ }
18
+
19
+ export interface ReviewCardProps {
20
+ /** Header-first: a small muted eyebrow above the body — e.g. "Proposed". */
21
+ eyebrow?: string;
22
+ /** Header-first: a headline above the body — a record name, a proposal. */
23
+ title?: string;
24
+ confidence?: ConfidenceLevel;
25
+ confidenceScore?: number;
26
+ /** One line of WHY — the rationale the human weighs. */
27
+ rationale?: string;
28
+ /** The proposal BODY — render it however fits: a `RecordFields`, a `MatchSides`
29
+ * pairing, a spec stack, a chip row. Omit for a title-only proposal. */
30
+ children?: ReactNode;
31
+ /** Decision actions, left→right (the last reads as the primary affirmative):
32
+ * Accept / Edit / Dismiss, Confirm / Remove, Accept / Reassign / Not-a-match —
33
+ * whatever the proposal's verbs are. Nothing applies until the host acts. */
34
+ actions?: ReviewAction[];
35
+ /** Resolved → the card COLLAPSES to a quiet one-line row (a check or ✕ + a
36
+ * summary + Undo), so a stack reads as a tidy checklist of what's decided. */
37
+ status?: "open" | "accepted" | "dismissed";
38
+ /** The summary text in the resolved row (defaults to `title`; required when
39
+ * body-first, since the body is arbitrary). */
40
+ summary?: string;
41
+ /** Resolved outcome words. Default "Accepted" / "Dismissed"; set per surface
42
+ * ("Confirmed" / "Removed", `Matched · …` / "Dismissed"). */
43
+ acceptedLabel?: string;
44
+ dismissedLabel?: string;
45
+ onUndo?: () => void;
46
+ }
47
+
48
+ /**
49
+ * THE single-proposal review card — an AI proposal the human accepts, edits, or
50
+ * dismisses, never auto-applied. It owns the chrome (the shared `reviewCardStyle`
51
+ * surface, `Confidence` + rationale, the decision footer, the collapse-to-a-row
52
+ * resolved state); the proposal BODY is `children` — a `RecordFields` (an
53
+ * extracted record to confirm), a `MatchSides` (a pairing to reconcile), a spec
54
+ * stack, anything.
55
+ *
56
+ * Layout follows the props: give it an `eyebrow`/`title` and the chrome LEADS
57
+ * (header-first — a value or record proposal); give it only `children` and the
58
+ * BODY leads, the confidence + reason sitting beneath it (body-first — a pairing,
59
+ * where the body is the hero). For a BATCH you decide each then commit, use the
60
+ * `ChangeReview` engine instead.
61
+ */
62
+ export function ReviewCard(props: ReviewCardProps) {
63
+ const status = props.status ?? "open";
64
+
65
+ if (status === "accepted" || status === "dismissed") {
66
+ const accepted = status === "accepted";
67
+ return (
68
+ <View style={styles.resolvedRow}>
69
+ <Icon name={accepted ? "check" : "x"} size={15} color={accepted ? solid("emerald") : colors.zinc[400]} />
70
+ <Text
71
+ size="sm"
72
+ weight="medium"
73
+ color={accepted ? "default" : "muted"}
74
+ numberOfLines={1}
75
+ style={[{ flex: 1 }, accepted ? null : styles.strike]}
76
+ >
77
+ {props.summary ?? props.title ?? ""}
78
+ </Text>
79
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
80
+ {accepted ? props.acceptedLabel ?? "Accepted" : props.dismissedLabel ?? "Dismissed"}
81
+ </Text>
82
+ {props.onUndo ? <Button title="Undo" color="muted" shape="rounded" onPress={props.onUndo} /> : null}
83
+ </View>
84
+ );
85
+ }
86
+
87
+ const headed = !!(props.eyebrow || props.title);
88
+ const hasConfidence = props.confidence != null || props.confidenceScore != null;
89
+ const confidence = hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null;
90
+ const actions = props.actions ?? [];
91
+
92
+ return (
93
+ <View style={reviewCardStyle}>
94
+ {/* Header-first: eyebrow + confidence, title, rationale ABOVE the body. */}
95
+ {props.eyebrow ? (
96
+ <View style={styles.headRow}>
97
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>
98
+ {props.eyebrow}
99
+ </Text>
100
+ {confidence}
101
+ </View>
102
+ ) : null}
103
+ {props.title ? (
104
+ <View style={styles.headRow}>
105
+ <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={2}>
106
+ {props.title}
107
+ </Text>
108
+ {!props.eyebrow ? confidence : null}
109
+ </View>
110
+ ) : null}
111
+ {headed && props.rationale ? (
112
+ <Text size="sm" color="muted">
113
+ {props.rationale}
114
+ </Text>
115
+ ) : null}
116
+
117
+ {props.children ? <View>{props.children}</View> : null}
118
+
119
+ {/* Body-first: the confidence + reason sit BENEATH the body, as one unit. */}
120
+ {!headed && (hasConfidence || props.rationale) ? (
121
+ <View style={styles.basis}>
122
+ {confidence}
123
+ {props.rationale ? (
124
+ <Text size="sm" color="muted">
125
+ {props.rationale}
126
+ </Text>
127
+ ) : null}
128
+ </View>
129
+ ) : null}
130
+
131
+ {actions.length > 0 ? (
132
+ <View style={styles.footer}>
133
+ {actions.map((a, i) => {
134
+ const kind = a.kind ?? (i === actions.length - 1 ? "primary" : "muted");
135
+ return <Button key={a.label} title={a.label} color={kind} shape="rounded" onPress={a.onPress} />;
136
+ })}
137
+ </View>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
142
+
143
+ const styles = StyleSheet.create({
144
+ headRow: { flexDirection: "row", alignItems: "center", gap: 10 },
145
+ // Body-first: confidence on top, the reason beneath — "this sure, because…".
146
+ basis: { alignItems: "flex-start", gap: 6 },
147
+ // The decisions sit below a hairline so the CTA row reads as a deliberate
148
+ // footer, not buttons crowding the body.
149
+ footer: {
150
+ flexDirection: "row",
151
+ alignItems: "center",
152
+ justifyContent: "flex-end",
153
+ gap: 8,
154
+ borderTopWidth: 1,
155
+ borderTopColor: colors.zinc[100],
156
+ paddingTop: 14,
157
+ },
158
+ resolvedRow: {
159
+ flexDirection: "row",
160
+ alignItems: "center",
161
+ gap: 10,
162
+ backgroundColor: colors.white,
163
+ borderWidth: 1,
164
+ borderColor: colors.zinc[100],
165
+ borderRadius: 10,
166
+ paddingLeft: 12,
167
+ paddingRight: 8,
168
+ paddingVertical: 6,
169
+ minHeight: 44,
170
+ },
171
+ strike: { textDecorationLine: "line-through" },
172
+ });
package/src/match_row.tsx DELETED
@@ -1,133 +0,0 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors } from "./colors";
3
- import { Text } from "./text";
4
- import { Icon } from "./icon";
5
- import { Button } from "./button";
6
- import { Confidence, type ConfidenceLevel } from "./confidence";
7
-
8
- export interface MatchSide {
9
- /** Primary line — the record name / id. */
10
- title: string;
11
- /** Secondary line — amount, date, customer. */
12
- detail?: string;
13
- }
14
-
15
- export interface MatchRowProps {
16
- /** The known item we're finding a counterpart for. */
17
- source: MatchSide;
18
- /** The agent's proposed counterpart. Omit → the "no confident match" state. */
19
- match?: MatchSide;
20
- /** One line of WHY the agent paired them. */
21
- rationale?: string;
22
- confidence?: ConfidenceLevel;
23
- confidenceScore?: number;
24
- onAccept?: () => void;
25
- /** Pick a different counterpart — the host opens its candidate list. */
26
- onReassign?: () => void;
27
- onDismiss?: () => void;
28
- acceptLabel?: string;
29
- /** Resolved → settles to a quiet outcome line. */
30
- status?: "open" | "accepted" | "dismissed";
31
- }
32
-
33
- /**
34
- * An AI-proposed PAIRING — a known item, an arrow, the agent's proposed
35
- * counterpart; a confidence meter and one muted line of why. The human accepts,
36
- * reassigns, or rejects; nothing links on its own. Deliberately spare: two
37
- * sides, an arrow, the reason. The unit of an AI reconciliation / dedup /
38
- * correlation queue. Unlike `Suggestion` (a single proposed value) this is
39
- * two-sided; unlike the deterministic reconcile recipe, the agent reasons it.
40
- */
41
- export function MatchRow(props: MatchRowProps) {
42
- const status = props.status ?? "open";
43
- const resolved = status !== "open";
44
- const hasMatch = props.match != null;
45
- const hasConfidence = props.confidence != null || props.confidenceScore != null;
46
-
47
- if (resolved) {
48
- return (
49
- <View style={[styles.card, styles.resolved]}>
50
- <View style={styles.pair}>
51
- <Side side={props.source} />
52
- </View>
53
- <Text size="xs" color="muted" weight="medium">
54
- {status === "accepted" ? (hasMatch ? `Matched · ${props.match!.title}` : "Matched") : "Dismissed"}
55
- </Text>
56
- </View>
57
- );
58
- }
59
-
60
- return (
61
- <View style={styles.card}>
62
- <View style={styles.pair}>
63
- <Side side={props.source} />
64
- <Icon name="arrow-right" size={16} color={colors.zinc[400]} />
65
- {hasMatch ? (
66
- <Side side={props.match!} right />
67
- ) : (
68
- <View style={[styles.side, styles.sideRight]}>
69
- <Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1}>
70
- No confident match
71
- </Text>
72
- <Text size="xs" color="muted" align="right" numberOfLines={1}>
73
- Pick a counterpart
74
- </Text>
75
- </View>
76
- )}
77
- </View>
78
-
79
- {hasConfidence || props.rationale ? (
80
- <View style={styles.meta}>
81
- {hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
82
- {props.rationale ? (
83
- <Text size="xs" color="muted" style={{ flex: 1 }} numberOfLines={2}>
84
- {props.rationale}
85
- </Text>
86
- ) : null}
87
- </View>
88
- ) : null}
89
-
90
- <View style={styles.footer}>
91
- {props.onDismiss ? <Button title="Not a match" color="muted" shape="rounded" onPress={props.onDismiss} /> : null}
92
- {props.onReassign ? (
93
- <Button title={hasMatch ? "Reassign" : "Find match"} color="secondary" shape="rounded" onPress={props.onReassign} />
94
- ) : null}
95
- {hasMatch && props.onAccept ? (
96
- <Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
97
- ) : null}
98
- </View>
99
- </View>
100
- );
101
- }
102
-
103
- function Side({ side, right }: { side: MatchSide; right?: boolean }) {
104
- return (
105
- <View style={[styles.side, right ? styles.sideRight : null]}>
106
- <Text size="sm" weight="medium" numberOfLines={1} align={right ? "right" : undefined}>
107
- {side.title}
108
- </Text>
109
- {side.detail ? (
110
- <Text size="xs" color="muted" numberOfLines={1} align={right ? "right" : undefined}>
111
- {side.detail}
112
- </Text>
113
- ) : null}
114
- </View>
115
- );
116
- }
117
-
118
- const styles = StyleSheet.create({
119
- card: {
120
- borderWidth: 1,
121
- borderColor: colors.border,
122
- backgroundColor: colors.white,
123
- borderRadius: 12,
124
- padding: 16,
125
- gap: 12,
126
- },
127
- resolved: { backgroundColor: colors.zinc[50] },
128
- pair: { flexDirection: "row", alignItems: "center", gap: 12 },
129
- side: { flex: 1, gap: 2 },
130
- sideRight: { alignItems: "flex-end" },
131
- meta: { flexDirection: "row", alignItems: "center", gap: 12 },
132
- footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
133
- });
@@ -1,149 +0,0 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid } from "./colors";
3
- import { Text } from "./text";
4
- import { Icon } from "./icon";
5
- import { Button } from "./button";
6
- import { InlineTextInput } from "./inline_text_input";
7
- import { Confidence, type ConfidenceLevel } from "./confidence";
8
-
9
- export interface RecordField {
10
- label: string;
11
- value: string;
12
- /** Unit suffix shown after the value (₫, kg, pcs). */
13
- unit?: string;
14
- /** Mark a low-confidence field worth double-checking — a subtle amber dot. */
15
- uncertain?: boolean;
16
- }
17
-
18
- export interface RecordReviewProps {
19
- /** A short identifier for the record ("Carton 1", a code, a name). */
20
- title?: string;
21
- fields: RecordField[];
22
- confidence?: ConfidenceLevel;
23
- confidenceScore?: number;
24
- /** Edit a field — fired on save of the inline editor (blur / Enter). When set,
25
- * each value is click-to-edit; omit for a read-only review. */
26
- onEditField?: (index: number, value: string) => void;
27
- /** Confirm the record — it collapses to a summary the host can re-open. */
28
- onConfirm?: () => void;
29
- /** Remove / skip the record (don't add it). */
30
- onRemove?: () => void;
31
- /** Re-open a confirmed or removed record. */
32
- onUndo?: () => void;
33
- status?: "pending" | "confirmed" | "removed";
34
- }
35
-
36
- /**
37
- * One extracted record under review before it's saved — the agent's fields, each
38
- * click-to-edit (`InlineTextInput`), with a confidence and Confirm / Remove. On
39
- * confirm (or remove) the card COLLAPSES to a one-line summary with Undo, so a
40
- * long extraction reads as a tidy checklist of what's been reviewed. Stack
41
- * several under a "Save all" to capture a whole document → table; the source +
42
- * streaming + save belong to the host (see `tpl_extract`). Replaces per-field
43
- * confirmable estimates with a whole-record review.
44
- */
45
- export function RecordReview(props: RecordReviewProps) {
46
- const status = props.status ?? "pending";
47
- const hasConfidence = props.confidence != null || props.confidenceScore != null;
48
-
49
- if (status === "confirmed" || status === "removed") {
50
- const removed = status === "removed";
51
- return (
52
- <View style={styles.collapsed}>
53
- <Icon name={removed ? "x" : "check"} size={15} color={removed ? colors.zinc[400] : solid("emerald")} />
54
- <Text
55
- size="sm"
56
- weight="medium"
57
- color={removed ? "muted" : "default"}
58
- numberOfLines={1}
59
- style={[{ flex: 1 }, removed ? styles.strike : null]}
60
- >
61
- {props.title ?? props.fields[0]?.value ?? "Record"}
62
- </Text>
63
- <Text size="sm" weight="medium" color="muted">
64
- {removed ? "Removed" : "Confirmed"}
65
- </Text>
66
- {props.onUndo ? <Button title="Undo" color="muted" shape="rounded" onPress={props.onUndo} /> : null}
67
- </View>
68
- );
69
- }
70
-
71
- return (
72
- <View style={styles.card}>
73
- <View style={styles.header}>
74
- <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
75
- {props.title ?? "Record"}
76
- </Text>
77
- {hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
78
- </View>
79
-
80
- <View style={styles.fields}>
81
- {props.fields.map((f, i) => (
82
- <View key={`${f.label}-${i}`} style={styles.fieldRow}>
83
- <View style={styles.labelCol}>
84
- {f.uncertain ? <View style={styles.uncertainDot} /> : null}
85
- <Text size="sm" color="muted" numberOfLines={1}>
86
- {f.label}
87
- </Text>
88
- </View>
89
- <View style={styles.valueCol}>
90
- <View style={{ flex: 1 }}>
91
- {props.onEditField ? (
92
- <InlineTextInput value={f.value} accessibilityLabel={`Edit ${f.label}`} onSave={(next) => props.onEditField?.(i, next)} />
93
- ) : (
94
- <Text size="sm" weight="medium" tabular>
95
- {f.value}
96
- </Text>
97
- )}
98
- </View>
99
- {f.unit ? (
100
- <Text size="sm" color="muted">
101
- {f.unit}
102
- </Text>
103
- ) : null}
104
- </View>
105
- </View>
106
- ))}
107
- </View>
108
-
109
- {props.onConfirm || props.onRemove ? (
110
- <View style={styles.footer}>
111
- {props.onRemove ? <Button title="Remove" color="muted" shape="rounded" onPress={props.onRemove} /> : null}
112
- {props.onConfirm ? <Button title="Confirm" color="secondary" shape="rounded" onPress={props.onConfirm} /> : null}
113
- </View>
114
- ) : null}
115
- </View>
116
- );
117
- }
118
-
119
- const styles = StyleSheet.create({
120
- card: {
121
- borderWidth: 1,
122
- borderColor: colors.border,
123
- backgroundColor: colors.white,
124
- borderRadius: 12,
125
- padding: 16,
126
- gap: 12,
127
- },
128
- header: { flexDirection: "row", alignItems: "center", gap: 10 },
129
- fields: { gap: 2 },
130
- fieldRow: { flexDirection: "row", alignItems: "center", gap: 16, minHeight: 34 },
131
- labelCol: { flexDirection: "row", alignItems: "center", gap: 6, width: 132 },
132
- uncertainDot: { width: 6, height: 6, borderRadius: 999, backgroundColor: solid("amber") },
133
- valueCol: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 },
134
- collapsed: {
135
- flexDirection: "row",
136
- alignItems: "center",
137
- gap: 10,
138
- backgroundColor: colors.white,
139
- borderWidth: 1,
140
- borderColor: colors.border,
141
- borderRadius: 12,
142
- paddingLeft: 14,
143
- paddingRight: 8,
144
- paddingVertical: 6,
145
- minHeight: 48,
146
- },
147
- strike: { textDecorationLine: "line-through" },
148
- footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
149
- });
@@ -1,92 +0,0 @@
1
- import { ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
3
- import { colors } from "./colors";
4
- import { Text } from "./text";
5
- import { Button } from "./button";
6
- import { Confidence, type ConfidenceLevel } from "./confidence";
7
-
8
- export interface SuggestionProps {
9
- /** The proposal headline — what the AI suggests. */
10
- title: string;
11
- /** One line of WHY — the rationale the human is weighing. */
12
- rationale?: string;
13
- confidence?: ConfidenceLevel;
14
- confidenceScore?: number;
15
- /** The proposal body — render the proposed value/preview however fits (a
16
- * field stack, a chip row, a diagram). Omit for a title-only suggestion. */
17
- children?: ReactNode;
18
- onAccept?: () => void;
19
- /** Tweak before accepting — the host opens its editor. */
20
- onEdit?: () => void;
21
- onDismiss?: () => void;
22
- acceptLabel?: string;
23
- /** Once resolved the card settles to a quiet outcome line (no actions). */
24
- status?: "open" | "accepted" | "dismissed";
25
- }
26
-
27
- /**
28
- * The core review-for-approval surface: an AI proposal the human accepts,
29
- * edits, or dismisses — never auto-applied. A "PROPOSED" eyebrow marks it as
30
- * the agent's, a `Confidence` meter says how sure, and the rationale is set as a
31
- * left-ruled margin note — the machine's reasoning, quoted and kept apart from
32
- * the facts and your controls. The decision is the human's. For a shortlist,
33
- * stack several (rank by confidence, the top one first). Pair with `AgentRun`
34
- * (what produced it) and `ChangeReview` (when the proposal edits existing state).
35
- */
36
- export function Suggestion(props: SuggestionProps) {
37
- const status = props.status ?? "open";
38
- const hasConfidence = props.confidence != null || props.confidenceScore != null;
39
- return (
40
- <View style={[styles.card, status !== "open" ? styles.resolved : null]}>
41
- <View style={styles.eyebrow}>
42
- <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>
43
- Proposed
44
- </Text>
45
- {status === "open" && hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
46
- </View>
47
-
48
- <Text size="md" weight="semibold" numberOfLines={2}>
49
- {props.title}
50
- </Text>
51
-
52
- {props.rationale ? (
53
- <View style={styles.note}>
54
- <Text size="sm" color="muted">
55
- {props.rationale}
56
- </Text>
57
- </View>
58
- ) : null}
59
-
60
- {props.children ? <View>{props.children}</View> : null}
61
-
62
- {status !== "open" ? (
63
- <Text size="xs" color="muted" weight="medium">
64
- {status === "accepted" ? "Accepted" : "Dismissed"}
65
- </Text>
66
- ) : (
67
- <View style={styles.footer}>
68
- {props.onDismiss ? <Button title="Dismiss" color="muted" shape="rounded" onPress={props.onDismiss} /> : null}
69
- {props.onEdit ? <Button title="Edit" color="secondary" shape="rounded" onPress={props.onEdit} /> : null}
70
- {props.onAccept ? (
71
- <Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
72
- ) : null}
73
- </View>
74
- )}
75
- </View>
76
- );
77
- }
78
-
79
- const styles = StyleSheet.create({
80
- card: {
81
- borderWidth: 1,
82
- borderColor: colors.border,
83
- backgroundColor: colors.white,
84
- borderRadius: 12,
85
- padding: 16,
86
- gap: 12,
87
- },
88
- resolved: { backgroundColor: colors.zinc[50] },
89
- eyebrow: { flexDirection: "row", alignItems: "center", gap: 8 },
90
- note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 },
91
- footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
92
- });