@lotics/ui 4.4.0 → 4.6.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.
@@ -6,38 +6,38 @@ import {
6
6
  TextInputKeyPressEventData,
7
7
  View,
8
8
  } from "react-native";
9
- import { colors, solid, tint } from "./colors";
10
- import { Icon } from "./icon";
11
- import { IconButton } from "./icon_button";
12
- import { ActivityIndicator } from "./activity_indicator";
13
- import { ACTIVE_RING } from "./control_surface";
9
+ import { colors } from "./colors";
10
+ import { Button } from "./button";
14
11
  import { getInputTextStyle, fontFamilyRegular } from "./text_utils";
15
12
 
16
13
  export interface PromptFieldProps {
17
14
  value: string;
18
15
  onChangeText: (value: string) => void;
19
- /** Fired on Enter (without Shift) or the send button. Receives the trimmed
20
- * text; never fires empty or while busy. */
16
+ /** Fired on Enter or the send button. Receives the trimmed text; never fires
17
+ * empty or while busy. */
21
18
  onSubmit: (value: string) => void;
22
19
  placeholder?: string;
23
- /** The agent is working — the field locks and the send slot shows a spinner.
24
- * This is the imperative command surface: one prompt does work, you watch it
25
- * stream (see `AgentRun`), you don't chat. */
20
+ /** The agent is working — the field locks. Typically the host swaps the field
21
+ * for `AgentProgress` while busy (the composer MORPHS into the progress
22
+ * pill); the two share the same pill geometry so the swap reads as a morph. */
26
23
  busy?: boolean;
27
24
  disabled?: boolean;
28
25
  autoFocus?: boolean;
29
26
  accessibilityLabel?: string;
30
- /** When set, a paperclip appears at the left of the footer — the composer can
31
- * attach files (e.g. the photo that starts a run). The host opens the picker. */
27
+ /** When set, a circular attach button sits at the left of the row — the
28
+ * composer can attach files (e.g. the photo that starts a run). */
32
29
  onAttach?: () => void;
33
30
  }
34
31
 
32
+ const ROW = 40;
33
+
35
34
  /**
36
- * The natural-language command surface for an AI app — a prompt box that
37
- * triggers agent work, NOT a chat composer. Enter sends (Shift+Enter for a
38
- * newline); while the agent runs, the field locks and the send slot spins. The
39
- * violet sparkle marks it as the AI input. Pair with `AgentRun` (the streamed
40
- * work it kicks off) and a session history of the outputs it produces.
35
+ * The natural-language command surface for an AI app — a compact single-row
36
+ * composer pill that triggers agent work, NOT a chat composer. A circular attach
37
+ * `Button` on the left, the input filling the row at the same height as the
38
+ * buttons, a circular send-arrow `Button` on the right; Enter sends. While the
39
+ * agent runs the host swaps this for `AgentProgress` same pill geometry, so it
40
+ * morphs into the progress indicator.
41
41
  */
42
42
  export function PromptField(props: PromptFieldProps) {
43
43
  const { value, onChangeText, onSubmit, placeholder, busy, disabled, autoFocus, accessibilityLabel, onAttach } = props;
@@ -50,8 +50,6 @@ export function PromptField(props: PromptFieldProps) {
50
50
  onSubmit(value.trim());
51
51
  }, [value, busy, disabled, onSubmit]);
52
52
 
53
- // Web: Enter sends, Shift+Enter inserts a newline. RN's key event wraps the
54
- // DOM event, so shiftKey/preventDefault are read defensively off nativeEvent.
55
53
  const onKeyPress = useCallback(
56
54
  (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
57
55
  const ne = e.nativeEvent as unknown as { key: string; shiftKey?: boolean };
@@ -64,87 +62,47 @@ export function PromptField(props: PromptFieldProps) {
64
62
  );
65
63
 
66
64
  return (
67
- <View
68
- style={[
69
- styles.box,
70
- focused ? { borderColor: colors.zinc[900], boxShadow: ACTIVE_RING } : null,
71
- disabled ? { backgroundColor: colors.zinc[50] } : null,
72
- ]}
73
- >
74
- <View style={styles.inputRow}>
75
- <View style={styles.glyph}>
76
- <Icon name="sparkles" size={14} color={solid("violet")} />
77
- </View>
78
- <RNTextInput
79
- accessibilityLabel={accessibilityLabel ?? placeholder}
80
- style={[styles.input, getInputTextStyle()]}
81
- value={value}
82
- onChangeText={onChangeText}
83
- onKeyPress={onKeyPress}
84
- onFocus={() => setFocused(true)}
85
- onBlur={() => setFocused(false)}
86
- placeholder={placeholder}
87
- placeholderTextColor={colors.zinc[400]}
88
- editable={!busy && !disabled}
89
- autoFocus={autoFocus}
90
- multiline
91
- submitBehavior="newline"
92
- />
93
- </View>
94
- <View style={styles.footer}>
95
- {onAttach ? (
96
- <IconButton icon="paperclip" color="none" size="sm" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
97
- ) : null}
98
- <View style={{ flex: 1 }} />
99
- {busy ? (
100
- <View style={styles.sendSlot}>
101
- <ActivityIndicator size={18} color={solid("violet")} />
102
- </View>
103
- ) : (
104
- <IconButton
105
- icon="arrow-up"
106
- color="primary"
107
- accessibilityLabel="Send"
108
- onPress={submit}
109
- disabled={!canSubmit}
110
- />
111
- )}
112
- </View>
65
+ <View style={[styles.box, focused ? styles.boxFocused : null, disabled ? styles.boxDisabled : null]}>
66
+ {onAttach ? (
67
+ <Button icon="paperclip" color="secondary" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
68
+ ) : null}
69
+ <RNTextInput
70
+ accessibilityLabel={accessibilityLabel ?? placeholder}
71
+ style={[styles.input, getInputTextStyle(), { outline: "none", outlineStyle: "none", outlineWidth: 0 } as object]}
72
+ value={value}
73
+ onChangeText={onChangeText}
74
+ onKeyPress={onKeyPress}
75
+ onSubmitEditing={submit}
76
+ onFocus={() => setFocused(true)}
77
+ onBlur={() => setFocused(false)}
78
+ placeholder={placeholder}
79
+ placeholderTextColor={colors.zinc[400]}
80
+ editable={!busy && !disabled}
81
+ autoFocus={autoFocus}
82
+ />
83
+ <Button icon="arrow-up" color="primary" accessibilityLabel="Send" onPress={submit} disabled={!canSubmit} />
113
84
  </View>
114
85
  );
115
86
  }
116
87
 
117
88
  const styles = StyleSheet.create({
118
89
  box: {
119
- borderWidth: 1,
90
+ flexDirection: "row",
91
+ alignItems: "center",
92
+ gap: 6,
93
+ minHeight: 52,
94
+ borderWidth: 1.5,
120
95
  borderColor: colors.border,
121
- borderRadius: 14,
96
+ borderRadius: 999,
122
97
  backgroundColor: colors.white,
123
- paddingHorizontal: 12,
124
- paddingTop: 10,
125
- paddingBottom: 8,
126
- gap: 6,
127
- },
128
- inputRow: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
129
- glyph: {
130
- width: 24,
131
- height: 24,
132
- borderRadius: 7,
133
- alignItems: "center",
134
- justifyContent: "center",
135
- backgroundColor: tint("violet", 0.1),
136
- marginTop: 2,
98
+ paddingHorizontal: 6,
137
99
  },
100
+ boxFocused: { borderColor: colors.zinc[900] },
101
+ boxDisabled: { backgroundColor: colors.zinc[50] },
138
102
  input: {
139
103
  flex: 1,
140
- minHeight: 48,
141
- maxHeight: 160,
142
- paddingTop: 4,
143
- paddingBottom: 0,
104
+ height: ROW,
105
+ paddingHorizontal: 10,
144
106
  fontFamily: fontFamilyRegular,
145
- // RN-Web focus outline is handled by the box ring, not the raw input.
146
- outlineStyle: "none",
147
107
  } as object,
148
- footer: { flexDirection: "row", alignItems: "center" },
149
- sendSlot: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
150
108
  });
@@ -0,0 +1,149 @@
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
+ });
@@ -0,0 +1,139 @@
1
+ import { StyleSheet, View, type ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Button } from "./button";
5
+ import { SpecList, type SpecRow } from "./spec_list";
6
+
7
+ export interface ScoredOptionProps {
8
+ /** Rank in the shortlist (1 = top). */
9
+ rank?: number;
10
+ title: string;
11
+ /** Secondary line — the provider, the route summary. */
12
+ subtitle?: string;
13
+ /** 0–1 score the agent assigned — drives the score bar + figure. */
14
+ score?: number;
15
+ /** One line on why it ranks where it does. */
16
+ rationale?: string;
17
+ /** Key specs (price, transit, …) — rendered as a dense `SpecList`. */
18
+ specs?: SpecRow[];
19
+ /** The agent's top pick — a "RECOMMENDED" label + a heavier border. */
20
+ recommended?: boolean;
21
+ selected?: boolean;
22
+ onSelect?: () => void;
23
+ selectLabel?: string;
24
+ }
25
+
26
+ /**
27
+ * An AI-RANKED candidate in a shortlist — rank, score, the agent's one-line
28
+ * reason (a margin note), and the key specs — that the human picks from. The
29
+ * agent scores and orders; the choice stays the human's. Mark the agent's top
30
+ * pick `recommended` (a label + heavier border, no colour). The unit of an AI
31
+ * compare / shortlist / recommend surface (quotes, carriers, suppliers, plans).
32
+ * Monochrome and iconless. Stack several, highest rank first.
33
+ */
34
+ export function ScoredOption(props: ScoredOptionProps) {
35
+ const { rank, title, subtitle, score, rationale, specs, recommended, selected, onSelect, selectLabel } = props;
36
+ const pct = score != null ? Math.round(score * 100) : null;
37
+ return (
38
+ <View style={[styles.card, recommended ? styles.recCard : null, selected ? styles.selected : null]}>
39
+ {recommended ? (
40
+ <Text size="xs" color="default" weight="semibold">
41
+ Recommended
42
+ </Text>
43
+ ) : null}
44
+
45
+ <View style={styles.head}>
46
+ {rank != null ? (
47
+ <View style={styles.rank}>
48
+ <Text size="sm" weight="semibold" style={{ color: colors.zinc[700] }}>
49
+ {rank}
50
+ </Text>
51
+ </View>
52
+ ) : null}
53
+ <View style={styles.titleCol}>
54
+ <Text size="md" weight="semibold" numberOfLines={1}>
55
+ {title}
56
+ </Text>
57
+ {subtitle ? (
58
+ <Text size="xs" color="muted" numberOfLines={1}>
59
+ {subtitle}
60
+ </Text>
61
+ ) : null}
62
+ </View>
63
+ {pct != null ? (
64
+ <View style={styles.scoreCol}>
65
+ <Text size="lg" weight="semibold" tabular>
66
+ {pct}
67
+ </Text>
68
+ <Text size="xs" color="muted">
69
+ score
70
+ </Text>
71
+ </View>
72
+ ) : null}
73
+ </View>
74
+
75
+ {pct != null ? (
76
+ <View style={styles.track}>
77
+ <View style={[styles.fill, { width: `${pct}%` }]} />
78
+ </View>
79
+ ) : null}
80
+
81
+ {rationale ? (
82
+ <View style={styles.note}>
83
+ <Text size="sm" color="muted">
84
+ {rationale}
85
+ </Text>
86
+ </View>
87
+ ) : null}
88
+
89
+ {specs && specs.length > 0 ? <SpecList rows={specs} dense /> : null}
90
+
91
+ {onSelect ? (
92
+ <View style={styles.footer}>
93
+ <Button
94
+ title={selected ? "Selected" : selectLabel ?? "Choose"}
95
+ icon={selected ? "check" : undefined}
96
+ color={selected ? "secondary" : "primary"}
97
+ shape="rounded"
98
+ onPress={onSelect}
99
+ />
100
+ </View>
101
+ ) : null}
102
+ </View>
103
+ );
104
+ }
105
+
106
+ const RING: ViewStyle = { boxShadow: `0 0 0 2px ${colors.zinc[900]}` } as ViewStyle;
107
+
108
+ const styles = StyleSheet.create({
109
+ card: {
110
+ borderWidth: 1,
111
+ borderColor: colors.border,
112
+ backgroundColor: colors.white,
113
+ borderRadius: 12,
114
+ padding: 16,
115
+ gap: 12,
116
+ },
117
+ recCard: { borderColor: colors.zinc[400] },
118
+ selected: RING,
119
+ footer: { flexDirection: "row", justifyContent: "flex-end" },
120
+ head: { flexDirection: "row", alignItems: "center", gap: 12 },
121
+ rank: {
122
+ width: 28,
123
+ height: 28,
124
+ borderRadius: 8,
125
+ alignItems: "center",
126
+ justifyContent: "center",
127
+ backgroundColor: colors.zinc[100],
128
+ },
129
+ titleCol: { flex: 1, gap: 1 },
130
+ scoreCol: { alignItems: "flex-end" },
131
+ track: {
132
+ height: 6,
133
+ borderRadius: 999,
134
+ backgroundColor: colors.zinc[100],
135
+ overflow: "hidden",
136
+ },
137
+ fill: { height: "100%", borderRadius: 999, backgroundColor: colors.zinc[900] },
138
+ note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 10 },
139
+ });
package/src/sources.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { StyleSheet, View } from "react-native";
2
- import { colors } from "./colors";
2
+ import { colors, solid, tint, type ColorName } from "./colors";
3
3
  import { Text } from "./text";
4
4
  import { Icon, type IconName } from "./icon";
5
5
  import { PressableHighlight } from "./pressable_highlight";
@@ -10,7 +10,7 @@ export interface SourceRef {
10
10
  id: string;
11
11
  label: string;
12
12
  kind?: SourceKind;
13
- /** A locator under the label — "page 2", a record code, a column. */
13
+ /** A locator after the label — "page 2", a record code, a column. */
14
14
  detail?: string;
15
15
  }
16
16
 
@@ -18,36 +18,41 @@ export interface SourcesProps {
18
18
  sources: SourceRef[];
19
19
  /** Eyebrow above the chips. Default "Sources". */
20
20
  label?: string;
21
- /** Pass to make each chip openable (jump to the record / document / page). */
21
+ /** Pass to make each chip openable (jump to the record / document / page). The
22
+ * chips then show a hover state + an open glyph; the host does the navigation. */
22
23
  onOpen?: (source: SourceRef) => void;
23
24
  }
24
25
 
25
- const KIND_ICON: Record<SourceKind, IconName> = {
26
- record: "box",
27
- document: "file-text",
28
- table: "table-2",
29
- web: "external-link",
30
- knowledge: "book-open",
26
+ // Each kind carries its own glyph + colour — provenance you can recognise at a
27
+ // glance (echoing the file-badge palette: document red, table green, …).
28
+ const KIND: Record<SourceKind, { icon: IconName; color: ColorName }> = {
29
+ document: { icon: "file-text", color: "red" },
30
+ table: { icon: "table-2", color: "emerald" },
31
+ record: { icon: "box", color: "blue" },
32
+ knowledge: { icon: "book-open", color: "amber" },
33
+ web: { icon: "globe", color: "sky" },
31
34
  };
32
35
 
33
36
  /**
34
37
  * Where the AI's output came FROM — a row of source chips under a generated
35
- * answer, summary, or extracted value, each openable. Provenance is what makes
36
- * AI output verifiable; show it on anything the agent produced from data it
37
- * read (extraction, summarization, Q&A). Renders nothing for an empty list.
38
+ * answer, summary, or extracted value, each with a per-kind coloured glyph.
39
+ * When `onOpen` is set each chip is pressable (hover wash + an open glyph) and
40
+ * the host navigates to the record / document / page. Provenance is what makes
41
+ * AI output verifiable; show it on anything the agent produced from data it read.
42
+ * Renders nothing for an empty list.
38
43
  */
39
44
  export function Sources(props: SourcesProps) {
40
45
  if (props.sources.length === 0) return null;
41
46
  return (
42
- <View style={{ gap: 6 }}>
43
- <Text size="xs" color="muted" transform="uppercase">
47
+ <View style={{ gap: 8 }}>
48
+ <Text size="xs" color="muted" weight="medium">
44
49
  {props.label ?? "Sources"}
45
50
  </Text>
46
51
  <View style={styles.row}>
47
52
  {props.sources.map((s) =>
48
53
  props.onOpen ? (
49
- <PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} style={styles.chip}>
50
- <SourceChip source={s} />
54
+ <PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
55
+ <SourceChip source={s} openable />
51
56
  </PressableHighlight>
52
57
  ) : (
53
58
  <View key={s.id} style={styles.chip}>
@@ -60,10 +65,13 @@ export function Sources(props: SourcesProps) {
60
65
  );
61
66
  }
62
67
 
63
- function SourceChip({ source }: { source: SourceRef }) {
68
+ function SourceChip({ source, openable }: { source: SourceRef; openable?: boolean }) {
69
+ const k = KIND[source.kind ?? "document"];
64
70
  return (
65
71
  <>
66
- <Icon name={KIND_ICON[source.kind ?? "document"]} size={12} color={colors.zinc[500]} />
72
+ <View style={[styles.iconDisc, { backgroundColor: tint(k.color, 0.14) }]}>
73
+ <Icon name={k.icon} size={13} color={solid(k.color)} />
74
+ </View>
67
75
  <Text size="xs" weight="medium" numberOfLines={1}>
68
76
  {source.label}
69
77
  </Text>
@@ -72,6 +80,7 @@ function SourceChip({ source }: { source: SourceRef }) {
72
80
  {source.detail}
73
81
  </Text>
74
82
  ) : null}
83
+ {openable ? <Icon name="external-link" size={12} color={colors.zinc[400]} /> : null}
75
84
  </>
76
85
  );
77
86
  }
@@ -81,12 +90,20 @@ const styles = StyleSheet.create({
81
90
  chip: {
82
91
  flexDirection: "row",
83
92
  alignItems: "center",
84
- gap: 6,
85
- paddingHorizontal: 10,
93
+ gap: 8,
94
+ paddingLeft: 5,
95
+ paddingRight: 10,
86
96
  paddingVertical: 5,
87
- borderRadius: 8,
97
+ borderRadius: 10,
88
98
  borderWidth: 1,
89
99
  borderColor: colors.border,
90
100
  backgroundColor: colors.white,
91
101
  },
102
+ iconDisc: {
103
+ width: 24,
104
+ height: 24,
105
+ borderRadius: 7,
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ },
92
109
  });
@@ -0,0 +1,81 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Divider } from "./divider";
4
+
5
+ export interface SpecRow {
6
+ /** The line label (left). */
7
+ label: string;
8
+ /** The value (right). A number renders grouped + tabular. */
9
+ value: string | number;
10
+ /** Unit after the value (%, mm, kg, ₫). */
11
+ unit?: string;
12
+ /** A heavier value — a headline figure or a sub-total inside the list. */
13
+ emphasis?: boolean;
14
+ }
15
+
16
+ export interface SpecListProps {
17
+ rows: SpecRow[];
18
+ /** A pinned summary row, set off by a divider above and a heavier weight —
19
+ * the total of a duty / cost / dimension breakdown. */
20
+ total?: SpecRow;
21
+ /** Tighter rows + xs labels for a dense side panel. */
22
+ dense?: boolean;
23
+ }
24
+
25
+ /**
26
+ * A labeled-value breakdown — the structured "spec sheet": a duty table, a
27
+ * dimensions list, a cost split, the exact numbers behind an answer. Each row is
28
+ * just label → value (· unit); an optional `total` pins the sum under a divider.
29
+ * Deliberately minimal — two columns, hierarchy from weight and tabular figures.
30
+ * The right-hand panel of a lookup / answer surface, the specs of a compared
31
+ * option.
32
+ */
33
+ export function SpecList({ rows, total, dense }: SpecListProps) {
34
+ return (
35
+ <View>
36
+ {rows.map((r, i) => (
37
+ <SpecLine key={`${r.label}-${i}`} row={r} dense={dense} />
38
+ ))}
39
+ {total ? (
40
+ <>
41
+ <Divider paddingVertical={dense ? 5 : 7} />
42
+ <SpecLine row={{ ...total, emphasis: true }} dense={dense} isTotal />
43
+ </>
44
+ ) : null}
45
+ </View>
46
+ );
47
+ }
48
+
49
+ function SpecLine({ row, dense, isTotal }: { row: SpecRow; dense?: boolean; isTotal?: boolean }) {
50
+ const { label, value, unit, emphasis } = row;
51
+ const display = typeof value === "number" ? value.toLocaleString("en-US") : value;
52
+ return (
53
+ <View style={[styles.row, { minHeight: dense ? 26 : 32 }]}>
54
+ <Text
55
+ size={dense ? "xs" : "sm"}
56
+ color={isTotal ? "default" : "muted"}
57
+ weight={isTotal ? "semibold" : "regular"}
58
+ numberOfLines={1}
59
+ style={styles.labelText}
60
+ >
61
+ {label}
62
+ </Text>
63
+ <View style={styles.valueRow}>
64
+ <Text size={dense && !isTotal ? "sm" : "md"} weight={emphasis ? "semibold" : "medium"} tabular>
65
+ {display}
66
+ </Text>
67
+ {unit ? (
68
+ <Text size={dense ? "xs" : "sm"} color="muted">
69
+ {unit}
70
+ </Text>
71
+ ) : null}
72
+ </View>
73
+ </View>
74
+ );
75
+ }
76
+
77
+ const styles = StyleSheet.create({
78
+ row: { flexDirection: "row", alignItems: "baseline", justifyContent: "space-between", gap: 16 },
79
+ labelText: { flexShrink: 1 },
80
+ valueRow: { flexDirection: "row", alignItems: "baseline", gap: 4 },
81
+ });