@lotics/ui 7.19.3 → 9.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.
Files changed (75) hide show
  1. package/AGENTS.md +298 -159
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_dieline.tsx +3 -3
  7. package/examples/tpl_documents.tsx +790 -0
  8. package/examples/tpl_item_list.tsx +1015 -124
  9. package/examples/tpl_lookup.tsx +37 -10
  10. package/examples/tpl_pick.tsx +3 -3
  11. package/examples/tpl_pivot.tsx +1 -1
  12. package/examples/tpl_record.tsx +1354 -0
  13. package/examples/tpl_report.tsx +7 -7
  14. package/examples/tpl_rollup.tsx +6 -6
  15. package/examples/tpl_shifts.tsx +2 -2
  16. package/examples/tpl_statements.tsx +221 -0
  17. package/examples/tpl_stock.tsx +7 -7
  18. package/examples/tpl_task_board.tsx +48 -32
  19. package/examples/tpl_tasks.tsx +47 -47
  20. package/examples/tpl_tower.tsx +2 -2
  21. package/package.json +9 -9
  22. package/src/agent_run.tsx +1 -1
  23. package/src/capture_row.tsx +59 -0
  24. package/src/change_review.tsx +732 -236
  25. package/src/checklist.tsx +104 -0
  26. package/src/chip.tsx +12 -3
  27. package/src/composer.tsx +22 -1
  28. package/src/confidence.tsx +1 -1
  29. package/src/control_surface.ts +0 -14
  30. package/src/detail_row.tsx +137 -10
  31. package/src/finding.tsx +112 -80
  32. package/src/floating_action_bar.tsx +1 -1
  33. package/src/inline_date_picker.tsx +8 -3
  34. package/src/inline_edit.tsx +40 -10
  35. package/src/inline_member_select.tsx +3 -0
  36. package/src/inline_number_input.tsx +5 -2
  37. package/src/inline_select.tsx +8 -3
  38. package/src/inline_tag_select.tsx +140 -0
  39. package/src/inline_text_input.tsx +5 -2
  40. package/src/inline_time_picker.tsx +5 -2
  41. package/src/ledger.tsx +220 -0
  42. package/src/locale.tsx +43 -16
  43. package/src/progress_bar.tsx +32 -1
  44. package/src/record_summary.tsx +101 -0
  45. package/src/section_heading.tsx +16 -8
  46. package/src/sources.tsx +8 -5
  47. package/src/suggestion_chip.tsx +47 -0
  48. package/src/use_section_nav.test.ts +69 -0
  49. package/src/use_section_nav.ts +59 -0
  50. package/examples/tpl_assistant.tsx +0 -174
  51. package/examples/tpl_billing.tsx +0 -344
  52. package/examples/tpl_briefing.tsx +0 -121
  53. package/examples/tpl_compare.tsx +0 -133
  54. package/examples/tpl_crosscheck.tsx +0 -120
  55. package/examples/tpl_detail.tsx +0 -232
  56. package/examples/tpl_directory.tsx +0 -260
  57. package/examples/tpl_draft.tsx +0 -163
  58. package/examples/tpl_extract.tsx +0 -193
  59. package/examples/tpl_intake.tsx +0 -206
  60. package/examples/tpl_match.tsx +0 -134
  61. package/examples/tpl_order.tsx +0 -482
  62. package/examples/tpl_quick.tsx +0 -211
  63. package/examples/tpl_ratedesk.tsx +0 -386
  64. package/examples/tpl_record_plain.tsx +0 -259
  65. package/examples/tpl_settings.tsx +0 -178
  66. package/examples/tpl_timeline.tsx +0 -244
  67. package/examples/tpl_triage.tsx +0 -112
  68. package/examples/tpl_wizard.tsx +0 -223
  69. package/src/discrepancy.tsx +0 -114
  70. package/src/match_sides.tsx +0 -79
  71. package/src/record_fields.tsx +0 -68
  72. package/src/review_card.tsx +0 -172
  73. package/src/scored_option.tsx +0 -138
  74. package/src/spec_list.tsx +0 -81
  75. package/src/triage_row.tsx +0 -99
@@ -0,0 +1,104 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { ActionMenu, type ActionMenuItem } from "./action_menu";
4
+
5
+ // The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
6
+ // (row height, ring/title alignment, one trailing column width) while the
7
+ // CONTENT stays composed: a `CheckCircle` (live or read-only) in `control`,
8
+ // a struck `InlineTextInput` or plain `Text` as the title, an
9
+ // `InlineMemberSelect` or nothing in `trailing`, and `menu` for the row's
10
+ // ⋯ options (Delete lives BEHIND the menu — indirection that prevents
11
+ // accidental destructive taps). Suggested tasks are NOT rows: offer the record
12
+ // type's commons as `SuggestionChip`s under the list (tap = materialize,
13
+ // ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
14
+ // list as its add affordance. There is deliberately NO monolithic Task
15
+ // component — richer task-management rows (expand affordances, tag/clip meta,
16
+ // board cards) compose their own anatomy directly.
17
+ //
18
+ // <Checklist trailingWidth={140}>
19
+ // <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
20
+ // menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
21
+ // accessibilityLabel: "Task options: …" }}>
22
+ // <InlineTextInput background="transparent" struck={done} … />
23
+ // </ChecklistRow>
24
+ // <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
25
+ // <CaptureRow … />
26
+ // </Checklist>
27
+
28
+ interface ChecklistContextValue {
29
+ trailingWidth?: number;
30
+ }
31
+
32
+ const ChecklistContext = createContext<ChecklistContextValue>({});
33
+
34
+ export interface ChecklistProps {
35
+ /** Fixed width of every row's trailing cell (an assignee select) — set it
36
+ * once so the column lines up; omit when rows carry no trailing. */
37
+ trailingWidth?: number;
38
+ children: ReactNode;
39
+ }
40
+
41
+ export function Checklist(props: ChecklistProps) {
42
+ const { trailingWidth, children } = props;
43
+ return (
44
+ <ChecklistContext.Provider value={{ trailingWidth }}>
45
+ <View style={styles.list}>{children}</View>
46
+ </ChecklistContext.Provider>
47
+ );
48
+ }
49
+
50
+ interface ChecklistRowBaseProps {
51
+ /** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
52
+ control: ReactNode;
53
+ /** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
54
+ children: ReactNode;
55
+ /** The row's ⋯ options menu (destructive items last, `danger: true`) —
56
+ * Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
57
+ * destroy a task. Name the task in the label ("Task options: Book the
58
+ * carrier"). The ⋯ column aligns only when every row in the list carries
59
+ * a menu — keep its presence uniform per list. */
60
+ menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
61
+ }
62
+
63
+ /** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
64
+ export type ChecklistRowProps = ChecklistRowBaseProps &
65
+ (
66
+ | {
67
+ /** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
68
+ trailing?: ReactNode;
69
+ meta?: never;
70
+ }
71
+ | {
72
+ /** SECOND line under the title (indented past the ring) for a NARROW
73
+ * surface — a drawer or popover checklist: put the assignee/due
74
+ * editors here so the title keeps the full width and stays readable. */
75
+ meta?: ReactNode;
76
+ trailing?: never;
77
+ }
78
+ );
79
+
80
+ /** One task line: control · title (flex) · the aligned trailing cell · ⋯,
81
+ * with an optional indented `meta` line below for narrow surfaces. */
82
+ export function ChecklistRow(props: ChecklistRowProps) {
83
+ const { control, children, trailing, menu, meta } = props;
84
+ const { trailingWidth } = useContext(ChecklistContext);
85
+ return (
86
+ <View>
87
+ <View style={styles.row}>
88
+ {control}
89
+ <View style={styles.title}>{children}</View>
90
+ {trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
91
+ {menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
92
+ </View>
93
+ {meta != null ? <View style={styles.meta}>{meta}</View> : null}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ const styles = StyleSheet.create({
99
+ list: { gap: 4 },
100
+ row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
101
+ title: { flex: 1 },
102
+ // Indent past the ring (20) + the row gap (12) so meta aligns with the title.
103
+ meta: { paddingLeft: 32, paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
104
+ });
package/src/chip.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Ref } from "react";
2
2
  import { IconButton } from "./icon_button";
3
+ import { useLoticsLocale } from "./locale";
3
4
  import { PressableHighlight } from "./pressable_highlight";
4
5
  import { chipSurfaceStyle } from "./control_surface";
5
6
  import { StyleSheet, View } from "react-native";
@@ -8,19 +9,27 @@ interface ChipProps {
8
9
  testID?: string;
9
10
  children: React.ReactNode;
10
11
  onPress?: () => void;
12
+ /** Accessible name of the press target. The pressable pill announces as a
13
+ * button either way — its name derives from the children when they are
14
+ * self-describing text; pass this when they aren't (icon-led content). */
15
+ accessibilityLabel?: string;
11
16
  onDismiss?: () => void;
12
17
  dismissTooltip?: string;
13
18
  ref?: Ref<View>;
14
19
  }
15
20
 
16
21
  export function Chip(props: ChipProps) {
17
- const { testID, children, onPress, onDismiss, dismissTooltip, ref } = props;
22
+ const { testID, children, onPress, accessibilityLabel, onDismiss, dismissTooltip, ref } = props;
23
+ const labels = useLoticsLocale().chip;
18
24
 
19
25
  return (
20
26
  <View ref={ref}>
21
27
  {onPress ? (
22
28
  <PressableHighlight
23
- focusRing testID={testID}
29
+ focusRing
30
+ testID={testID}
31
+ accessibilityRole="button"
32
+ accessibilityLabel={accessibilityLabel}
24
33
  onPress={onPress}
25
34
  style={(state) => [chipSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
26
35
  >
@@ -34,7 +43,7 @@ export function Chip(props: ChipProps) {
34
43
  <IconButton
35
44
  icon="x"
36
45
  tooltip={dismissTooltip}
37
- accessibilityLabel={dismissTooltip ?? "Remove"}
46
+ accessibilityLabel={dismissTooltip ?? labels.remove}
38
47
  onPress={onDismiss}
39
48
  testID={testID ? `${testID}-remove` : undefined}
40
49
  />
package/src/composer.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { type ReactNode, type Ref, useCallback, useEffect, useState } fro
2
2
  import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
3
3
  import { IconButton } from "./icon_button";
4
4
  import { colors } from "./colors";
5
+ import { Text } from "./text";
5
6
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
6
7
  import { useAutoGrowHeight } from "./use_auto_grow_height";
7
8
 
@@ -189,7 +190,6 @@ export function Composer(props: ComposerProps) {
189
190
  autoFocus={autoFocus}
190
191
  onChangeText={handleChangeText}
191
192
  onSubmitEditing={handleSend}
192
- placeholder={placeholder}
193
193
  placeholderTextColor={colors.zinc[500]}
194
194
  value={text}
195
195
  onFocus={() => setFocused(true)}
@@ -208,6 +208,15 @@ export function Composer(props: ComposerProps) {
208
208
  spreadInputProps?.style,
209
209
  ]}
210
210
  />
211
+ {/* The placeholder is OURS, not the textarea's: a long hint must never
212
+ wrap into clipped lines inside the pill — one line, ellipsized. */}
213
+ {emptyText && placeholder ? (
214
+ <View pointerEvents="none" style={[styles.placeholderOverlay, expanded ? styles.placeholderOverlayExpanded : { paddingHorizontal: 10 }]}>
215
+ <Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: -0.4 }}>
216
+ {placeholder}
217
+ </Text>
218
+ </View>
219
+ ) : null}
211
220
  </View>
212
221
  );
213
222
 
@@ -314,6 +323,18 @@ const styles = StyleSheet.create({
314
323
  field: {
315
324
  flex: 1,
316
325
  },
326
+ placeholderOverlay: {
327
+ position: "absolute",
328
+ left: 0,
329
+ right: 0,
330
+ top: 0,
331
+ bottom: 0,
332
+ justifyContent: "center",
333
+ },
334
+ placeholderOverlayExpanded: {
335
+ justifyContent: "flex-start",
336
+ paddingTop: 2,
337
+ },
317
338
  textInput: {
318
339
  flex: 1,
319
340
  fontFamily: fontFamilyRegular,
@@ -36,7 +36,7 @@ export function levelFromScore(score: number): ConfidenceLevel {
36
36
  * How sure the AI is — a calibrated high / medium / low read as a 3-tick meter
37
37
  * (emerald / amber / zinc) beside the level word. The fill count and the colour
38
38
  * both carry the level. The human weights an AI proposal or estimate by it. Pair
39
- * with `ReviewCard` or `ChangeReview`.
39
+ * with `ChangeReview`.
40
40
  */
41
41
  export function Confidence(props: ConfidenceProps) {
42
42
  const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
@@ -39,20 +39,6 @@ export const CONTROL_TRANSITION = {
39
39
  transitionDuration: "0.12s",
40
40
  };
41
41
 
42
- /** THE review-card surface — the bordered white card every "AI proposes, you
43
- * decide" surface wears (`ReviewCard`, and the whole-set `ChangeReview`), so the
44
- * review family reads as ONE contract instead of look-alikes drifting apart. One
45
- * definition; each adds its own header / body / footer on top.
46
- * `reviewResolvedStyle` is the settled wash once decided. */
47
- export const reviewCardStyle: ViewStyle = {
48
- borderWidth: 1,
49
- borderColor: colors.border,
50
- backgroundColor: colors.white,
51
- borderRadius: 12,
52
- padding: 16,
53
- gap: 12,
54
- };
55
- export const reviewResolvedStyle: ViewStyle = { backgroundColor: colors.zinc[50] };
56
42
 
57
43
  /**
58
44
  * THE pill-surface contract — the single definition of the bordered, white,
@@ -1,24 +1,107 @@
1
- import { ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
1
+ import { createContext, ReactNode, useContext, useState } from "react";
2
+ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
3
4
  import { Text } from "./text";
4
5
 
6
+ interface DetailTableColumns {
7
+ labelWidth: number;
8
+ trailingWidth?: number;
9
+ minHeight: number;
10
+ /** Container too narrow for the side-by-side columns — rows render STACKED
11
+ * (label above a full-width value row). */
12
+ stacked: boolean;
13
+ }
14
+
15
+ const DetailTableContext = createContext<DetailTableColumns | null>(null);
16
+
17
+ /** The value cell must keep at least this width before the table gives up its
18
+ * side-by-side columns and stacks — below it, editors get crushed. */
19
+ const MIN_VALUE_WIDTH = 160;
20
+
21
+ export interface DetailTableProps {
22
+ /** Label column width (px). Default 130. */
23
+ labelWidth?: number;
24
+ /** Reserved width (px) for the trailing column. Set it when ANY row carries a
25
+ * trailing action/unit/badge: EVERY row then reserves the column, so all
26
+ * value cells share ONE width and the trailing items align at one x — the
27
+ * table look. Omit when no row has a trailing (values fill to the edge). */
28
+ trailingWidth?: number;
29
+ /** Min row height. Default 40 (`INLINE_CONTROL_HEIGHT`) so editor, static,
30
+ * and custom rows all sit on the inline-control baseline grid. */
31
+ minHeight?: number;
32
+ /** The usable width the value cell must keep before the table STACKS
33
+ * (default 160). Raise it when a cell holds MORE than one editor (an
34
+ * amount + method pair) — side-by-side columns that technically fit can
35
+ * still crush a multi-editor cell. */
36
+ minValueWidth?: number;
37
+ /** `DetailRow`s (each inherits the table's columns; row props override). */
38
+ children: ReactNode;
39
+ style?: StyleProp<ViewStyle>;
40
+ }
41
+
42
+ /**
43
+ * The column grid for a stack of `DetailRow`s — label · value · trailing laid
44
+ * out like a TABLE: one label width, one value width, one trailing width, set
45
+ * ONCE on the parent instead of repeated (and drifting) per row. Rows without a
46
+ * trailing still reserve the trailing column, so a `Copy` action on one row
47
+ * never makes its editor narrower than its neighbours'.
48
+ *
49
+ * <DetailTable labelWidth={130} trailingWidth={96}>
50
+ * <DetailRow label="Reference" trailing={<Button title="Copy" … />}>
51
+ * <InlineTextInput … />
52
+ * </DetailRow>
53
+ * <DetailRow label="Priority"><InlineSelect … /></DetailRow>
54
+ * </DetailTable>
55
+ *
56
+ * It owns the 6px row gap (zinc-50 editor chips need visible separation) and
57
+ * the 40px row height of the inline-control grid.
58
+ *
59
+ * RESPONSIVE: the table measures its own container (not the viewport — a table
60
+ * inside a narrow Drawer on a wide screen must adapt too). When the columns
61
+ * would crush the value cell below a usable width, every row STACKS: label
62
+ * above a full-width value row (trailing stays beside the value). No prop —
63
+ * the switch is automatic.
64
+ */
65
+ export function DetailTable(props: DetailTableProps) {
66
+ const { labelWidth = 130, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
67
+ const [width, setWidth] = useState<number | null>(null);
68
+ const stacked =
69
+ width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
70
+ return (
71
+ <DetailTableContext.Provider value={{ labelWidth, trailingWidth, minHeight, stacked }}>
72
+ <View
73
+ onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
74
+ // Measure-then-REVEAL: the unmeasured first frame renders invisible
75
+ // (opacity 0), so the first PAINT the user sees is already in the
76
+ // right mode — no table→stacked reshuffle as a drawer opens.
77
+ style={[stacked ? styles.tableStacked : styles.table, width == null ? styles.unmeasured : null, style]}
78
+ >
79
+ {children}
80
+ </View>
81
+ </DetailTableContext.Provider>
82
+ );
83
+ }
84
+
5
85
  export interface DetailRowProps {
6
86
  /** The field label — rendered `sm muted`. */
7
87
  label: ReactNode;
8
88
  /** The value — any node (a `Text`, a `Badge`, a stack). */
9
89
  children: ReactNode;
10
90
  /** An optional right-side slot, rendered as the LAST child of the row (after
11
- * `children`) — a small action `Button`, a status `Badge`, a unit label, an
12
- * `IconButton`. In form mode (`labelWidth` set) the value column already fills
13
- * the row, so the trailing pins to the right edge automatically. */
91
+ * `children`) — a small action `Button`, a status `Badge`, an `IconButton`.
92
+ * NOT for units: "kg" / "₫" belong IN the value (`InlineNumberInput format`).
93
+ * In form mode (`labelWidth` set) the value column already fills
94
+ * the row, so the trailing pins to the right edge automatically. Inside a
95
+ * `DetailTable` with `trailingWidth` it sits in the reserved trailing column. */
14
96
  trailing?: ReactNode;
15
97
  /** Fixed label-column width (px) to align labels in a left column — for a peek
16
98
  * or form-like stack. Omit for a spread row: the label takes the slack and the
17
- * value sits at the right edge (the default drawer-detail look). */
99
+ * value sits at the right edge (the default drawer-detail look). Inside a
100
+ * `DetailTable` the table's `labelWidth` applies; this overrides per row. */
18
101
  labelWidth?: number;
19
102
  /** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
20
103
  labelSize?: "xs" | "sm";
21
- /** Min row height. Default 28. */
104
+ /** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
22
105
  minHeight?: number;
23
106
  }
24
107
 
@@ -27,23 +110,61 @@ export interface DetailRowProps {
27
110
  * `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
28
111
  * to the right edge); pass `labelWidth` to align labels in a fixed left column —
29
112
  * the value column then FILLS the rest of the row, so a stack of inline editors
30
- * all span the same width (none jumps wider on edit).
113
+ * all span the same width (none jumps wider on edit). For a stack of rows, wrap
114
+ * them in a `DetailTable`: the label/trailing column widths live ONCE on the
115
+ * parent and every row aligns.
31
116
  */
32
117
  export function DetailRow(props: DetailRowProps) {
33
- const { label, children, trailing, labelWidth, labelSize = "sm", minHeight = 28 } = props;
118
+ const table = useContext(DetailTableContext);
119
+ const { label, children, trailing, labelSize = "sm" } = props;
120
+ const labelWidth = props.labelWidth ?? table?.labelWidth;
121
+ const minHeight = props.minHeight ?? table?.minHeight ?? 28;
34
122
  const form = labelWidth != null;
123
+ if (table?.stacked) {
124
+ // Stacked mode wears the FORM grammar: the label renders exactly like a
125
+ // `FormField` label (medium, default ink), so a narrow record surface
126
+ // reads as one vocabulary with forms. The COMPONENTS stay separate — a
127
+ // FormField wraps a draft control validated and committed together, an
128
+ // inline editor self-persists — only the look converges.
129
+ return (
130
+ <View style={styles.stackedRow}>
131
+ <Text weight="medium" numberOfLines={1}>
132
+ {label}
133
+ </Text>
134
+ <View style={[styles.stackedValueRow, { minHeight }]}>
135
+ <View style={styles.value}>{children}</View>
136
+ {trailing != null ? trailing : null}
137
+ </View>
138
+ </View>
139
+ );
140
+ }
35
141
  return (
36
142
  <View style={[styles.row, { minHeight }]}>
37
143
  <Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
38
144
  {label}
39
145
  </Text>
40
146
  {form ? <View style={styles.value}>{children}</View> : children}
41
- {trailing != null ? trailing : null}
147
+ {table?.trailingWidth != null ? (
148
+ <View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
149
+ ) : trailing != null ? (
150
+ trailing
151
+ ) : null}
42
152
  </View>
43
153
  );
44
154
  }
45
155
 
46
156
  const styles = StyleSheet.create({
157
+ // 6px between rows — the zinc-50 editor chips need visible separation.
158
+ table: { gap: 6 },
159
+ // Stacked (narrow) mode: label-over-value blocks need more air between rows.
160
+ tableStacked: { gap: 14 },
161
+ unmeasured: { opacity: 0 },
162
+ stackedRow: { gap: 4 },
163
+ stackedValueRow: {
164
+ flexDirection: "row",
165
+ alignItems: "center",
166
+ gap: 12,
167
+ },
47
168
  row: {
48
169
  flexDirection: "row",
49
170
  alignItems: "center",
@@ -56,4 +177,10 @@ const styles = StyleSheet.create({
56
177
  // child fills it edge-to-edge. A status Badge or action is NOT a value — pass
57
178
  // it as `trailing` (pins right); one placed here would stretch full-width.
58
179
  value: { flex: 1 },
180
+ // The reserved trailing column: every row renders it (empty or not) so value
181
+ // cells share one width and trailing items align at one x, like a table.
182
+ trailingCell: {
183
+ flexDirection: "row",
184
+ alignItems: "center",
185
+ },
59
186
  });
package/src/finding.tsx CHANGED
@@ -1,110 +1,142 @@
1
- import { StyleSheet, View } from "react-native";
2
- import { colors, solid, type ColorName } from "./colors";
3
- import { Text } from "./text";
1
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { type ReactNode } from "react";
4
3
  import { Badge } from "./badge";
5
- import { Button } from "./button";
4
+ import { Text } from "./text";
6
5
  import { Sources, type SourceRef } from "./sources";
7
6
  import { useLoticsLocale } from "./locale";
7
+ import { colors, type ColorName } from "./colors";
8
8
 
9
9
  export type FindingSeverity = "critical" | "warning" | "info" | "positive";
10
10
 
11
+ /** The localized strings — the `finding` LoticsLocale slice. */
12
+ export type FindingLabels = Record<FindingSeverity, string> & {
13
+ /** The delta row's label in `FindingComparison`. */
14
+ difference: string;
15
+ };
16
+
17
+ const SEVERITY_COLOR: Record<FindingSeverity, ColorName> = {
18
+ critical: "red",
19
+ warning: "amber",
20
+ info: "zinc",
21
+ positive: "emerald",
22
+ };
23
+
11
24
  export interface FindingProps {
12
- severity?: FindingSeverity;
13
- /** Translated severity words. Defaults resolve through the locale ("Critical" …). */
14
- labels?: Partial<FindingLabels>;
15
- /** The headline — what the agent found. */
25
+ /** Ranked severity — the dot badge + its localized word. */
26
+ severity: FindingSeverity;
27
+ /** What the agent found — one line. */
16
28
  title: string;
17
- /** One or two lines explaining it. */
29
+ /** The explanation under the title. */
18
30
  detail?: string;
19
- /** A headline figure tied to the finding (a value, a delta, a count), shown in
20
- * the header row in the severity colour. */
31
+ /** The emphasized MEASURE of the finding ("−40 pcs") prominent in the
32
+ * body (lg, semibold), never a side note. */
21
33
  metric?: string;
22
- /** A short caption beside the metric giving it context ("exposure", "overdue",
23
- * "vs target"). */
34
+ /** What the metric compares ("invoice vs packing list"). */
24
35
  metricCaption?: string;
25
- /** Provenance — the records / documents the finding rests on. */
36
+ /** Provenance — the one `Sources` idiom, chips at the bottom. */
26
37
  sources?: SourceRef[];
27
- onOpenSource?: (s: SourceRef) => void;
28
- /** The single action the finding suggests. */
29
- action?: { label: string; onPress: () => void };
38
+ onOpenSource?: (source: SourceRef) => void;
39
+ /** Compose anything extra between the body and the sources — a band pair,
40
+ * an action row, a custom block. */
41
+ children?: ReactNode;
42
+ style?: StyleProp<ViewStyle>;
30
43
  }
31
44
 
32
- // Severity reads through a coloured dot badge + the order it's stacked in (most
33
- // severe first): red critical, amber warning, blue note, emerald on-track. The
34
- // metric takes the same colour. Words resolve prop → locale → English default.
35
- export type FindingLabels = Record<FindingSeverity, string>;
36
- const SEV_COLOR: Record<FindingSeverity, ColorName> = {
37
- critical: "red",
38
- warning: "amber",
39
- info: "blue",
40
- positive: "emerald",
41
- };
42
-
43
45
  /**
44
- * One ranked insight from an AI briefing / audit / anomaly scan — a severity dot
45
- * badge and a headline figure share the top row, the headline + explanation read
46
- * below, then provenance and the single action it suggests. Severity reads
47
- * through the coloured badge + metric and the stacking order (most severe first).
48
- * Unlike `Callout` (a flat inline status), a Finding is ranked, sourced, and
49
- * carries its own next action.
46
+ * One ranked insight from an AI check a cross-check discrepancy, an audit
47
+ * observation, a briefing item. Good defaults (severity word · title · detail
48
+ * · the PROMINENT metric · `Sources` chips) with a `children` slot for any
49
+ * extra composition. Display-only: a finding informs the verdict the host
50
+ * records; it decides nothing itself. Stack several most-severe first
51
+ * inside a `ChangeReview` wrap each in a display-only `Change` (the family's
52
+ * dividers apply); standalone, separate them yourself.
50
53
  */
51
54
  export function Finding(props: FindingProps) {
52
- const severity = props.severity ?? "info";
53
- const words = { ...useLoticsLocale().finding, ...props.labels };
54
- const sev = { word: words[severity], color: SEV_COLOR[severity] };
55
+ const words = useLoticsLocale().finding;
55
56
  return (
56
- <View style={styles.card}>
57
- <View style={styles.header}>
58
- <Badge variant="dot" color={sev.color} label={sev.word} />
59
- <View style={{ flex: 1 }} />
60
- {props.metric ? (
61
- <View style={styles.metric}>
62
- <Text size="lg" weight="semibold" tabular style={{ color: solid(sev.color), letterSpacing: -0.3 }}>
63
- {props.metric}
64
- </Text>
65
- {props.metricCaption ? (
66
- <Text size="xs" color="muted">
67
- {props.metricCaption}
68
- </Text>
69
- ) : null}
70
- </View>
71
- ) : null}
72
- </View>
73
-
74
- <View style={styles.body}>
75
- <Text size="md" weight="semibold" numberOfLines={2}>
76
- {props.title}
57
+ <View style={[styles.root, props.style]}>
58
+ <Badge variant="dot" color={SEVERITY_COLOR[props.severity]} label={words[props.severity]} />
59
+ <Text size="sm" weight="medium">
60
+ {props.title}
61
+ </Text>
62
+ {props.detail ? (
63
+ <Text size="sm" color="muted">
64
+ {props.detail}
77
65
  </Text>
78
- {props.detail ? (
79
- <Text size="sm" color="muted">
80
- {props.detail}
66
+ ) : null}
67
+ {props.metric ? (
68
+ <View style={styles.metricBlock}>
69
+ <Text size="lg" weight="semibold" tabular>
70
+ {props.metric}
81
71
  </Text>
82
- ) : null}
83
- </View>
84
-
72
+ {props.metricCaption ? (
73
+ <Text size="xs" color="muted">
74
+ {props.metricCaption}
75
+ </Text>
76
+ ) : null}
77
+ </View>
78
+ ) : null}
79
+ {props.children}
85
80
  {props.sources && props.sources.length > 0 ? (
86
- <Sources sources={props.sources} onOpen={props.onOpenSource} />
81
+ <Sources label={null} sources={props.sources} onOpen={props.onOpenSource} />
87
82
  ) : null}
88
- {props.action ? (
89
- <View style={styles.actionRow}>
90
- <Button title={props.action.label} color="secondary" onPress={props.action.onPress} />
83
+ </View>
84
+ );
85
+ }
86
+
87
+ export interface FindingComparisonProps {
88
+ /** The disagreeing sides — each value with where it came from
89
+ * ("invoice.pdf" · "480 pcs"). Two or more. */
90
+ values: { label: string; value: string }[];
91
+ /** The computed difference, EMPHASIZED ("−40 pcs"). */
92
+ delta?: string;
93
+ /** Label for the delta row. Defaults to the localized "Difference". */
94
+ deltaLabel?: string;
95
+ }
96
+
97
+ /**
98
+ * The expected-vs-actual body of a `Finding` — each side a labeled row
99
+ * (source · value), a hairline, then the DELTA emphasized. Compose it as the
100
+ * finding's children; it replaces ad-hoc metric text for any
101
+ * one-value-disagrees insight (quantities, totals, dates).
102
+ */
103
+ export function FindingComparison(props: FindingComparisonProps) {
104
+ const words = useLoticsLocale().finding;
105
+ return (
106
+ <View style={styles.comparison}>
107
+ {props.values.map((v, i) => (
108
+ <View key={`${v.label}-${i}`} style={styles.comparisonRow}>
109
+ <Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
110
+ {v.label}
111
+ </Text>
112
+ <Text size="sm" weight="semibold" tabular numberOfLines={1} style={{ flexShrink: 1 }}>
113
+ {v.value}
114
+ </Text>
91
115
  </View>
116
+ ))}
117
+ {props.delta ? (
118
+ <>
119
+ <View style={styles.comparisonRule} />
120
+ <View style={styles.comparisonRow}>
121
+ <Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
122
+ {props.deltaLabel ?? words.difference}
123
+ </Text>
124
+ <Text size="lg" weight="semibold" tabular>
125
+ {props.delta}
126
+ </Text>
127
+ </View>
128
+ </>
92
129
  ) : null}
93
130
  </View>
94
131
  );
95
132
  }
96
133
 
97
134
  const styles = StyleSheet.create({
98
- card: {
99
- borderWidth: 1,
100
- borderColor: colors.border,
101
- backgroundColor: colors.white,
102
- borderRadius: 12,
103
- padding: 16,
104
- gap: 12,
105
- },
106
- header: { flexDirection: "row", alignItems: "center", gap: 12 },
107
- metric: { flexDirection: "row", alignItems: "baseline", gap: 5 },
108
- body: { gap: 6 },
109
- actionRow: { flexDirection: "row", justifyContent: "flex-end" },
135
+ root: { gap: 6, alignItems: "flex-start" },
136
+ metricBlock: { gap: 1, paddingVertical: 2 },
137
+ comparison: { gap: 6, paddingVertical: 4, alignSelf: "stretch", maxWidth: 420 },
138
+ comparisonRow: { flexDirection: "row", alignItems: "baseline", gap: 12 },
139
+ comparisonLabel: { width: 130 },
140
+ comparisonRule: { height: 1, backgroundColor: colors.zinc[100] },
110
141
  });
142
+
@@ -31,7 +31,7 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
31
31
  <View pointerEvents="box-none" style={styles.wrap}>
32
32
  <Card style={styles.bar}>
33
33
  <Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
34
- <Button title={clearLabel} color="secondary" onPress={onClear} />
34
+ <Button title={clearLabel} color="muted" onPress={onClear} />
35
35
  {children}
36
36
  </Card>
37
37
  </View>