@lotics/ui 44.7.1 → 44.8.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.
@@ -47,7 +47,6 @@ import { Confidence } from "@lotics/ui/confidence";
47
47
  import { CardSelectItem } from "@lotics/ui/card_select_item";
48
48
  import { FileDropzone } from "@lotics/ui/file_dropzone";
49
49
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
50
- import { Link } from "@lotics/ui/link";
51
50
  import { TextInputField } from "@lotics/ui/text_input_field";
52
51
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
53
52
  import { InlineFiles } from "@lotics/ui/inline_files";
@@ -847,10 +846,15 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
847
846
  shape per card is found without reading the column. */}
848
847
  <DiffMark kind={dropped ? "removed" : "added"} />
849
848
  <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>{title}</Text>
849
+ {/* A control, not a Link. These MUTATE the change set;
850
+ they navigate nowhere. Blue underlined text promises a
851
+ destination, and `Link` announces `role="link"` — so a
852
+ template every app copies was teaching the one thing
853
+ composition.md § Buttons bans outright. */}
850
854
  {dropped ? (
851
- <Link size="sm" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`}>Keep</Link>
855
+ <Button color="secondary" title="Keep" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`} />
852
856
  ) : (
853
- <Link size="sm" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`}>Don&rsquo;t create</Link>
857
+ <Button color="secondary" title="Don’t create" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`} />
854
858
  )}
855
859
  </View>
856
860
  {/* A dropped card COLLAPSES rather than dimming in place:
@@ -997,7 +1001,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
997
1001
  {khach}
998
1002
  {CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " (new customer)"}
999
1003
  </Text>
1000
- <Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
1004
+ <Button color="secondary" title="Change" onPress={() => setKhach("")} accessibilityLabel="Change customer" />
1001
1005
  </View>
1002
1006
  )}
1003
1007
  </FormField>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.7.1",
3
+ "version": "44.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -213,6 +213,7 @@
213
213
  "./sort_header": "./src/sort_header.tsx",
214
214
  "./skeleton": "./src/skeleton.tsx",
215
215
  "./table": "./src/table.tsx",
216
+ "./table_fit": "./src/table_fit.ts",
216
217
  "./data_grid": "./src/data_grid.tsx",
217
218
  "./detail_row": "./src/detail_row.tsx",
218
219
  "./record_summary": "./src/record_summary.tsx",
package/src/accordion.tsx CHANGED
@@ -150,8 +150,13 @@ export interface AccordionMetaProps {
150
150
  /** Right-side context for AccordionHeader — an amount, date, or short hint.
151
151
  * xs muted, tabular for numerals. */
152
152
  export function AccordionMeta(props: AccordionMetaProps) {
153
+ // The meta yields before the title does. The title NAMES the disclosure; the
154
+ // meta only qualifies it — but with the title on `flex: 1` and the meta at its
155
+ // intrinsic width, the squeeze landed entirely on the name (measured at 375:
156
+ // title clipped 24% and ellipsised, meta untouched). Same inverted priority
157
+ // `PageHeader` fixed one rung up; resolved the same way here.
153
158
  return (
154
- <Text size="xs" color="muted" tabular>
159
+ <Text size="xs" color="muted" tabular numberOfLines={1} style={styles.meta}>
155
160
  {props.children}
156
161
  </Text>
157
162
  );
@@ -196,6 +201,9 @@ const styles = StyleSheet.create({
196
201
  titleText: {
197
202
  flex: 1,
198
203
  },
204
+ meta: {
205
+ flexShrink: 1,
206
+ },
199
207
  // Flush with the header's left edge — aligns with the title, like a Section
200
208
  // body. The chevron + position already say "inside"; no indent needed.
201
209
  content: {
@@ -48,11 +48,13 @@ export interface AgentRunLike {
48
48
  * the wrong document, and the only exit was to sit through it.
49
49
  */
50
50
  cancel: () => void;
51
- /** The breaking error that killed the run. Accepts `undefined` as well as
52
- * `null` so an SDK run — whose `error` is an OPTIONAL property satisfies
53
- * this without the caller reshaping the object the SDK docs say to hand
54
- * straight over. The pane normalizes both to absent. */
55
- error: string | null | undefined;
51
+ /** The breaking error that killed the run. OPTIONAL, not merely widened to
52
+ * include `undefined`: an optional property is not assignable to a required
53
+ * one however wide the required one's type, so declaring it required made the
54
+ * three-line usage in `ai_patterns.md` fail to compile against an SDK run —
55
+ * and every app that hit it reached for a `useMemo` reshape or a cast. The
56
+ * pane normalizes absent, `undefined` and `null` alike. */
57
+ error?: string | null | undefined;
56
58
  }
57
59
 
58
60
  export function AgentRunScope({ children }: { children: ReactNode }) {
package/src/card.tsx CHANGED
@@ -3,6 +3,7 @@ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Divider } from "./divider";
5
5
  import { InfoPopover } from "./info_popover";
6
+ import { useLoticsLocale } from "./locale";
6
7
  import { Text } from "./text";
7
8
 
8
9
  interface CardProps {
@@ -84,13 +85,18 @@ export interface CardHeaderTitleProps {
84
85
  * description line. Grows to push siblings (meta, actions) to the right.
85
86
  * Tabular numerals so id-like titles (SR-2026-0081) align across a list. */
86
87
  export function CardHeaderTitle(props: CardHeaderTitleProps) {
88
+ // The ⓘ's name comes from the pack, like every other one in the kit
89
+ // (`section_heading` at all three of its levels). This had the Vietnamese
90
+ // value of that very slot pasted in as a literal, which typechecks, renders,
91
+ // and announces Vietnamese to a screen reader in every other tenant's app.
92
+ const words = useLoticsLocale();
87
93
  return (
88
94
  <View style={styles.headerTitle}>
89
95
  <View style={styles.headerTitleRow}>
90
96
  <Text size="sm" weight="semibold" tabular>
91
97
  {props.children}
92
98
  </Text>
93
- {props.info ? <InfoPopover text={props.info} accessibilityLabel="Giải thích dữ liệu" /> : null}
99
+ {props.info ? <InfoPopover text={props.info} accessibilityLabel={words.sectionHeading.info} /> : null}
94
100
  </View>
95
101
  {props.description ? (
96
102
  <Text size="xs" color="muted">
@@ -33,8 +33,13 @@ export function CardSelectItem(props: CardSelectItemProps) {
33
33
  aria-pressed={selected}
34
34
  onPress={onPress}
35
35
  style={(state: PressableHighlightState) => {
36
- const active = selected || state.hovered || state.pressed || state.focusVisible;
37
- return [styles.container, active && styles.ring, style];
36
+ // Selection is a GROUND; hover/press/focus are a ring. They used to
37
+ // share one style, which made the card you had CHOSEN pixel-identical to
38
+ // the card under the pointer — the same defect 43.5.0 fixed one release
39
+ // earlier for `Table`'s selected row, and the reason the kit's own
40
+ // `tpl_lookup` routed around this component with a hand-rolled ground.
41
+ const ringed = state.hovered || state.pressed || state.focusVisible;
42
+ return [styles.container, selected && styles.selected, ringed && styles.ring, style];
38
43
  }}
39
44
  >
40
45
  {children}
@@ -56,4 +61,12 @@ const styles = StyleSheet.create({
56
61
  ring: {
57
62
  ...({ boxShadow: FOCUS_RING } as ViewStyle),
58
63
  },
64
+ // The accent wash + accent edge is the same "you are here" the selected table
65
+ // row and the active chip carry, so one signal means one thing product-wide.
66
+ // A ground rather than a heavier outline: among white cards a 1px border
67
+ // change is noise, and it vanishes entirely under the hover ring.
68
+ selected: {
69
+ backgroundColor: colors.accent_wash,
70
+ borderColor: colors.accent,
71
+ },
59
72
  });
@@ -38,12 +38,26 @@ export const CONTROL_CONTENT_HEIGHT = 28;
38
38
  export const CONTROL_PADDING_V = (CONTROL_HEIGHT - CONTROL_CONTENT_HEIGHT) / 2 - 1;
39
39
 
40
40
  /** The narrowest a control may be and still be usable — below it, editors get
41
- * crushed and option text has nowhere to go. It is the threshold at which a
42
- * `DetailTable` gives up its side-by-side columns and stacks, and the floor
43
- * under a field popover's width, so "too narrow to use" means one thing across
44
- * the kit rather than a number re-picked per component. */
41
+ * crushed and option text has nowhere to go. It is the floor under a field
42
+ * popover's width, so "too narrow to use" means one thing across the kit rather
43
+ * than a number re-picked per component. */
45
44
  export const MIN_CONTROL_WIDTH = 160;
46
45
 
46
+ /**
47
+ * The narrowest a record's VALUE column may be before `DetailTable` stops
48
+ * putting label and value side by side and stacks them instead.
49
+ *
50
+ * Deliberately larger than {@link MIN_CONTROL_WIDTH}, because the two answer
51
+ * different questions. A control's floor asks "can this still be operated"; a
52
+ * value column's asks "can this still be READ" — and a value column holds
53
+ * company names, addresses and reference codes, not just controls. Sharing the
54
+ * control's 160 put the stack threshold low enough that a 375px screen kept its
55
+ * two columns and handed the value ~170px: a name needing 221 got 104, a tax
56
+ * code needing 114 got 53. Both halves measured "fine" and the row was
57
+ * unreadable.
58
+ */
59
+ export const MIN_VALUE_WIDTH = 200;
60
+
47
61
  /** The system control radius — every interactive control surface (Button, the
48
62
  * inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
49
63
  * track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
package/src/data_grid.tsx CHANGED
@@ -5,6 +5,7 @@ import { FocusRingPressable } from "./focus_ring_pressable";
5
5
  import { Icon } from "./icon";
6
6
  import { Text } from "./text";
7
7
  import { type SortState, type SortHeaderLabels } from "./sort_header";
8
+ import { FLEX_MIN_WIDTH } from "./table_fit";
8
9
 
9
10
  export interface DataGridColumn<T> {
10
11
  key: string;
@@ -178,7 +179,14 @@ export const gridRowStyle: ViewStyle = {
178
179
 
179
180
  const styles = StyleSheet.create({
180
181
  row: gridRowStyle,
181
- flexCol: { flex: 1, minWidth: 0 },
182
+ // A FLOOR, not `minWidth: 0`. This grid sheds no columns — every column is a
183
+ // measure you came to compare, which is what separates it from `Table`'s
184
+ // register of objects — so the fixed columns hold their widths and the
185
+ // flexible identity column absorbed the entire squeeze. At 0 it absorbed it
186
+ // all the way to nothing: a narrow container rendered rows of unlabelled
187
+ // money, silently, with no overflow to notice. Past the floor the grid
188
+ // overflows and the caller scrolls it; see catalog.md § DataGrid.
189
+ flexCol: { flex: 1, minWidth: FLEX_MIN_WIDTH },
182
190
  head: {
183
191
  minHeight: 30,
184
192
  borderBottomWidth: 1,
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
- import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
5
+ import { CONTROL_HEIGHT, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
6
6
  import { Icon } from "./icon";
7
7
  import { Button } from "./button";
8
8
  import { FocusRingPressable } from "./focus_ring_pressable";
@@ -129,7 +129,14 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
129
129
  flexDirection: "row",
130
130
  alignItems: "center",
131
131
  gap: 8,
132
- paddingVertical: 9,
132
+ // The height comes from the shared constant, never from padding
133
+ // tuned against a particular leading. This was `paddingVertical: 9`,
134
+ // which was exactly 40 against the OLD `sm` 14/20 — then 43.5.0 made
135
+ // running text 14/24 and the same padding silently became 44, one
136
+ // control standing 4px proud of every sibling in its band. A ramp is
137
+ // allowed to move; a control's height is not derived from it.
138
+ height: CONTROL_HEIGHT,
139
+ justifyContent: "center",
133
140
  paddingHorizontal: 12,
134
141
  borderWidth: 1,
135
142
  // `colors.border` like every other control — this rested a step
@@ -15,6 +15,12 @@ export interface DateStampProps {
15
15
  onChange?: (iso: string) => void;
16
16
  /** Locale tag for the rendered date. Defaults to the kit's locale. */
17
17
  locale?: string;
18
+ /** Drop the year (`08/09` rather than `08/09/2026`). The stamp sits in
19
+ * columns beside dates a caller formats itself, and a year on one side of a
20
+ * row and not the other is the kind of mismatch nobody reports and everyone
21
+ * notices. Same option `formatDate` takes — the format itself stays there,
22
+ * so there is one place that decides what a date looks like. */
23
+ compact?: boolean;
18
24
  accessibilityLabel: string;
19
25
  }
20
26
 
@@ -44,7 +50,7 @@ export interface DateStampProps {
44
50
  * value therefore renders nothing at all.
45
51
  */
46
52
  export function DateStamp(props: DateStampProps) {
47
- const { value, onChange, locale, accessibilityLabel } = props;
53
+ const { value, onChange, locale, compact = false, accessibilityLabel } = props;
48
54
  const localeTag = useLocaleTag();
49
55
  const tag = locale ?? localeTag;
50
56
  const [hovered, setHovered] = useState(false);
@@ -55,7 +61,7 @@ export function DateStamp(props: DateStampProps) {
55
61
  // replaced. This is what the close path reads instead.
56
62
  const picked = useRef<string | null>(value);
57
63
 
58
- const shown = value ? formatDate(value, { locale: tag }) : "";
64
+ const shown = value ? formatDate(value, { locale: tag, compact }) : "";
59
65
 
60
66
  // Read-only, or nothing to show and no way to add one: render the text alone
61
67
  // so a caller can drop this into a column without a conditional.
@@ -1,6 +1,6 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
- import { MIN_CONTROL_WIDTH } from "./control_surface";
3
+ import { MIN_VALUE_WIDTH } from "./control_surface";
4
4
  import { SPACE } from "./spacing";
5
5
  import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
6
6
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
@@ -32,9 +32,9 @@ export interface DetailTableProps {
32
32
  * and custom rows all sit on the inline-control baseline grid. */
33
33
  minHeight?: number;
34
34
  /** The usable width the value cell must keep before the table STACKS
35
- * (default 160). Raise it when a cell holds MORE than one editor (an
36
- * amount + method pair) — side-by-side columns that technically fit can
37
- * still crush a multi-editor cell. */
35
+ * (default {@link MIN_VALUE_WIDTH}). Raise it when a cell holds MORE than one
36
+ * editor (an amount + method pair) — side-by-side columns that technically
37
+ * fit can still crush a multi-editor cell. */
38
38
  minValueWidth?: number;
39
39
  /** `DetailRow`s (each inherits the table's columns; row props override). */
40
40
  children: ReactNode;
@@ -68,7 +68,7 @@ export interface DetailTableProps {
68
68
  * above a full-width value. No prop — the switch is automatic.
69
69
  */
70
70
  export function DetailTable(props: DetailTableProps) {
71
- const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
71
+ const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
72
72
  const [width, setWidth] = useState<number | null>(null);
73
73
  const stacked = width != null && width < labelWidth + minValueWidth + 24;
74
74
  return (
@@ -100,12 +100,17 @@ export interface DetailRowProps extends FieldAnnotationProps {
100
100
  labelSize?: "xs" | "sm";
101
101
  /** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
102
102
  minHeight?: number;
103
- /** The value renders as FLAT text (an `InlineStatic`, a plain `Text`) rather
104
- * than a control-height chip. Annotations then tuck up by the control
105
- * band's slack, so the perceived gap under the TEXT equals the gap under a
106
- * chip without it, the band's invisible bottom half reads as a hole
107
- * between a static value and its description. Row height and label
108
- * centering are unchanged. */
103
+ /** The value is a RAW node a plain `Text`, a figure, a pair — sitting off the
104
+ * inline-control grid. NOT `InlineStatic` or `InlineSlot`: those compose the
105
+ * grid's own box, so their words already start at the control text inset and
106
+ * centre in the band, and `flat` there re-introduces both offsets it exists
107
+ * to remove. Annotations then align to the value on BOTH axes,
108
+ * because a control's box is what they were offset from and there is no box:
109
+ * they tuck up by the control band's slack, so the perceived gap under the
110
+ * TEXT equals the gap under a chip rather than leaving the band's invisible
111
+ * bottom half reading as a hole; and they drop the control's text inset, so
112
+ * the description starts on the value's own left edge instead of nine pixels
113
+ * inside it. Row height and label centering are unchanged. */
109
114
  flat?: boolean;
110
115
  }
111
116
 
@@ -160,7 +165,7 @@ export function DetailRow(props: DetailRowProps) {
160
165
  // text inset, defined once in `field_annotations` and rendered identically by
161
166
  // a checklist row's field. A record's field and a checklist's are the same
162
167
  // thing on two surfaces; they may not say a fault two different ways.
163
- const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
168
+ const annotations = <FieldAnnotations description={description} warning={warning} error={error} flat={flat} />;
164
169
  if (table?.stacked) {
165
170
  // Stacked mode wears the FORM grammar: the label renders exactly like a
166
171
  // `FormField` label (medium, default ink), so a narrow record surface
@@ -42,11 +42,11 @@ export function EmptyState(props: EmptyStateProps) {
42
42
  <Icon name={props.icon} size={28} color={colors.zinc[400]} />
43
43
  </View>
44
44
  ) : null}
45
- <Text size="sm" color="muted">
45
+ <Text size="sm" color="muted" style={styles.line}>
46
46
  {props.message}
47
47
  </Text>
48
48
  {props.hint ? (
49
- <Text size="xs" color="muted">
49
+ <Text size="xs" color="muted" style={styles.line}>
50
50
  {props.hint}
51
51
  </Text>
52
52
  ) : null}
@@ -63,4 +63,11 @@ const styles = StyleSheet.create({
63
63
  icon: {
64
64
  marginBottom: 4,
65
65
  },
66
+ // The container centres the BOX; without this the text inside it still sets
67
+ // left. Invisible while every line is short enough not to wrap — which is
68
+ // exactly the case a narrow container breaks, leaving a wrapped message
69
+ // reading left-aligned beside a one-line hint reading centred.
70
+ line: {
71
+ textAlign: "center",
72
+ },
66
73
  });
@@ -41,11 +41,11 @@ export function ErrorState(props: ErrorStateProps) {
41
41
  <View style={styles.icon}>
42
42
  <Icon name="triangle-alert" size={28} color={solid("red")} />
43
43
  </View>
44
- <Text size="sm" color="muted">
44
+ <Text size="sm" color="muted" style={styles.line}>
45
45
  {props.message}
46
46
  </Text>
47
47
  {props.detail ? (
48
- <Text size="xs" color="muted">
48
+ <Text size="xs" color="muted" style={styles.line}>
49
49
  {props.detail}
50
50
  </Text>
51
51
  ) : null}
@@ -66,6 +66,13 @@ const styles = StyleSheet.create({
66
66
  alignItems: "center",
67
67
  gap: 4,
68
68
  },
69
+ // Centred like `EmptyState`'s, and for the same reason: the container centres
70
+ // the BOX, not the text inside it. These two swap into one another as a read
71
+ // settles, so a difference here would show up as the copy jumping alignment at
72
+ // the moment a region fails.
73
+ line: {
74
+ textAlign: "center",
75
+ },
69
76
  icon: {
70
77
  marginBottom: 4,
71
78
  },
@@ -45,10 +45,31 @@ export function hasFieldAnnotation(props: FieldAnnotationProps): boolean {
45
45
  * they answer different questions ("this is broken" / "this will cost you" / "this is what the
46
46
  * field is for"), and hiding the rule that explains the failure is exactly the wrong economy.
47
47
  */
48
- export function FieldAnnotations(props: FieldAnnotationProps) {
48
+ export interface FieldAnnotationsProps extends FieldAnnotationProps {
49
+ /**
50
+ * The value above is FLAT text, not a painted control — so drop the control's
51
+ * text inset and sit on the value's own left edge.
52
+ *
53
+ * The inset exists to clear a control's border and padding, which is what puts
54
+ * an annotation under the value's WORDS instead of under the editor's
55
+ * invisible box. Under plain text there is no border and no padding to clear,
56
+ * so the same number becomes pure indent: label, value and description end up
57
+ * on THREE left edges where the screen only has two things to say. Nine pixels
58
+ * is small enough to read as sloppiness rather than as structure, and it
59
+ * appears only on the rows whose value happens not to be a control, so a
60
+ * column of fields drifts in and out of alignment down its own length.
61
+ *
62
+ * Hosts already know which kind of value they hold: `DetailRow` passes its
63
+ * `flat`, the same flag that tucks the block up by the control band's slack.
64
+ * One fact about the value, both corrections.
65
+ */
66
+ flat?: boolean;
67
+ }
68
+
69
+ export function FieldAnnotations(props: FieldAnnotationsProps) {
49
70
  if (!hasFieldAnnotation(props)) return null;
50
71
  return (
51
- <View style={styles.stack}>
72
+ <View style={[styles.stack, props.flat === true && styles.stackFlat]}>
52
73
  {props.error != null ? (
53
74
  <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
54
75
  {props.error}
@@ -73,4 +94,6 @@ const styles = StyleSheet.create({
73
94
  // with the editor's invisible box — and the 2px that separates the lines from each other,
74
95
  // which is the same rhythm the host puts between the control and this block.
75
96
  stack: { gap: 2, paddingLeft: CONTROL_TEXT_INSET },
97
+ // ...and nothing to clear when the value is flat text — see `flat`.
98
+ stackFlat: { paddingLeft: 0 },
76
99
  });
package/src/finding.tsx CHANGED
@@ -45,8 +45,8 @@ export interface FindingProps {
45
45
  /** What was found, in one line, in the reader's own nouns. */
46
46
  title: string;
47
47
  /**
48
- * The readings that disagree, in source order. Rendered inline beneath the
49
- * title as `source value · source value`.
48
+ * The readings that disagree, in source order. Rendered ONE PER LINE beneath
49
+ * the title, each as `source value` never joined by a separator.
50
50
  *
51
51
  * NEITHER is marked as the wrong one. A finding reports that two sources
52
52
  * disagree; which one is right is the reader's call, and a struck-through or
@@ -77,8 +77,15 @@ export interface FindingProps {
77
77
  }
78
78
 
79
79
  /**
80
- * ONE RANKED INSIGHT from an AI check — a cross-check discrepancy, an audit
81
- * observation, a briefing item.
80
+ * ONE RANKED DISCREPANCY from a cross-check — a contradiction between two
81
+ * readings, an audit observation, a briefing item.
82
+ *
83
+ * WHOEVER performed the check. Nothing here is AI-specific — the labels are
84
+ * four severity words and "difference" — but this opened "from an AI check",
85
+ * and a rules-derived contradiction (two papers disagreeing, a carrier moving
86
+ * an ETA) reads as out of scope on that wording. It is not: the next author who
87
+ * believes it hand-rolls a Callout and loses the ranking, the delta and the
88
+ * readings line.
82
89
  *
83
90
  * The readings sit at `sm`, the same size as the title. They were `xs` — a
84
91
  * miniature that said "secondary" by being hard to read, which is the wrong
@@ -134,11 +141,6 @@ export function Finding(props: FindingProps) {
134
141
  <View style={styles.readings}>
135
142
  {readings.map((reading, index) => (
136
143
  <View key={`${reading.source}-${index}`} style={styles.reading}>
137
- {index > 0 ? (
138
- <Text size="sm" color="muted">
139
- ·
140
- </Text>
141
- ) : null}
142
144
  <SourceName label={reading.source} onOpen={props.onOpenSource} />
143
145
  <Text size="sm" weight="medium" tabular numberOfLines={1}>
144
146
  {reading.value}
@@ -189,6 +191,13 @@ const styles = StyleSheet.create({
189
191
  // instance — the dot marks the left edge and the figure the right, so a
190
192
  // reader scanning a stack gets rank and magnitude in one pass.
191
193
  delta: { flexShrink: 0 },
192
- readings: { flexDirection: "row", flexWrap: "wrap", alignItems: "baseline", gap: 6 },
194
+ // ONE READING PER LINE. They were joined inline by a ` · `, which fails twice:
195
+ // a middot claims a relation while refusing to name it (the templated-metadata
196
+ // tell `docs/reviewing.md` bans outright), and it is the one mark a screen
197
+ // reader drops — so the separator carrying "these two disagree" never survived
198
+ // being read aloud, the same defect `CommentsButton` was fixed for. It also
199
+ // wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
200
+ // each source with its own value at every width and needs no separator at all.
201
+ readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
193
202
  reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
194
203
  });
@@ -5,6 +5,12 @@ export interface FormatMoneyOptions {
5
5
  * "486 tr ₫". For stat strips/cards where the full figure lives in the
6
6
  * table below — never for the table itself. */
7
7
  compact?: boolean;
8
+ /** Significant digits below the currency's minor unit. `Intl`'s currency
9
+ * style floors at that unit — 2 for USD — so a genuine per-unit price like
10
+ * an LCL rate of 0,019 US$/kg renders as `0,02`: a 5% error, printed as
11
+ * fact, with nothing on screen saying it was rounded. Set this on a UNIT
12
+ * price; leave it off for a total, where two decimals is the contract. */
13
+ maxFractionDigits?: number;
8
14
  }
9
15
 
10
16
  /**
@@ -29,10 +35,18 @@ export function formatCompactNumber(value: number, locale = "vi-VN"): string {
29
35
  * or the ₫ suffix.
30
36
  */
31
37
  export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
32
- const { locale = "vi-VN", currency = "VND", compact = false } = options;
38
+ const { locale = "vi-VN", currency = "VND", compact = false, maxFractionDigits } = options;
33
39
  if (compact && Math.abs(value) >= 1_000_000) {
34
40
  const suffixed = formatCompactNumber(value, locale);
35
41
  return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
36
42
  }
37
- return value.toLocaleString(locale, { style: "currency", currency });
43
+ return value.toLocaleString(locale, {
44
+ style: "currency",
45
+ currency,
46
+ // Raising the MAXIMUM alone is enough, and is what keeps a total looking
47
+ // like money: Intl holds its own minimum (2 for USD, 0 for VND), so 0,019
48
+ // gains its digits while 2750 still renders 2.750,00. Unset, both stay at
49
+ // the currency's own contract.
50
+ ...(maxFractionDigits != null ? { maximumFractionDigits: maxFractionDigits } : {}),
51
+ });
38
52
  }
@@ -259,6 +259,7 @@ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; activ
259
259
  return (hovered: boolean): StyleProp<ViewStyle>[] => [
260
260
  styles.view,
261
261
  (o.variant === "bare" || o.disabled) && styles.viewBare,
262
+ o.disabled === true && styles.viewInert,
262
263
  // Keyed on the VARIANT alone, never on `disabled` — a disabled `framed`
263
264
  // field shares the line above (it must not promise a press) but stays in its
264
265
  // column with the enabled fields beside it. Bleeding that one would pull a
@@ -396,9 +397,9 @@ interface InlineEditViewProps {
396
397
  /**
397
398
  * The view-mode box of an inline-editable field: the value as plain text on the
398
399
  * shared control surface — white with a 1px border when `framed`, frameless
399
- * until hover when `bare`. It hovers via its BORDER, never a grey wash, and the
400
- * cursor stays the default arrow: the surface itself is the affordance, and a
401
- * pointer would read as a button. The border is present at every state (merely
400
+ * until hover when `bare`. It hovers via its BORDER, never a grey wash, and it
401
+ * takes the pointer cursor every other control takes. The border is present at
402
+ * every state (merely
402
403
  * transparent when `bare` rests), so swapping to an input, or floating a
403
404
  * dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
404
405
  * the overlay trigger for the select/date inline editors (it forwards ref +
@@ -459,7 +460,7 @@ export function InlineEditView(props: InlineEditViewProps) {
459
460
  onFocus={onFocus}
460
461
  accessibilityRole="button"
461
462
  accessibilityLabel={accessibilityLabel}
462
- style={[styles.viewInner, multilineBox(numberOfLines)]}
463
+ style={[styles.viewInner, disabled === true && styles.viewInert, multilineBox(numberOfLines)]}
463
464
  >
464
465
  {content}
465
466
  </Pressable>
@@ -678,14 +679,33 @@ const styles = StyleSheet.create({
678
679
  flexDirection: "row",
679
680
  alignItems: "center",
680
681
  gap: 6,
681
- // An inline editor is an INPUT, not a button keep the default arrow
682
- // cursor (the chip + hover-border carry the affordance; a pointer reads
683
- // as a button press). Overrides the Pressable's pointer default.
684
- cursor: "auto",
682
+ // THE POINTER, like every other control in the kit.
683
+ //
684
+ // This box used to force the arrow back, on the reasoning that an inline
685
+ // editor is an input rather than a button and the chip plus hover-border
686
+ // already carried the affordance. Both halves fail. `bare` HAS no chip —
687
+ // nothing at rest by design — so the whole affordance was a 1px edge that
688
+ // appears on hover, which is invisible to touch and to anyone who never
689
+ // happens to sweep the value; a member's actual question was "how am I
690
+ // supposed to edit this?". And the arrow does not read as "input", it
691
+ // reads as INERT: it is what the page's own prose gets, so the one thing
692
+ // on the row that is theirs to change looked like the one thing that
693
+ // wasn't.
694
+ //
695
+ // The I-beam is not the alternative. It promises a caret and a selection,
696
+ // and `userSelect: "none"` below deliberately denies both so a drag edits
697
+ // instead of selecting — a false promise in the same family as the arrow.
698
+ // What is true of every variant is that pressing DOES something, which is
699
+ // exactly what the pointer says.
700
+ cursor: "pointer",
685
701
  },
686
702
  // Nothing at rest — the frame arrives on hover. Also how a DISABLED field
687
703
  // rests, whatever its variant: an inert value must not promise a press.
688
704
  viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
705
+ // ...and the cursor is half of that promise, so it goes back to the arrow
706
+ // for a disabled field only. `viewBare` cannot carry it: a `bare` field is
707
+ // enabled and wears the same style.
708
+ viewInert: { cursor: "auto" },
689
709
  // A bare field at rest looks like TEXT, so it aligns like text: its glyphs sit
690
710
  // on the column, not its invisible box. The frame's 8px inset is real padding
691
711
  // the eye cannot see at rest, and it put every bare value 8px right of the
@@ -716,7 +736,7 @@ const styles = StyleSheet.create({
716
736
  // The value region inside a verb-hosting resting field: carries the row layout
717
737
  // the surface would otherwise own. `userSelect` here rather than via the
718
738
  // `FocusRingPressable` prop — a drag across a value must edit, not select text.
719
- viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none", cursor: "auto" } as ViewStyle) },
739
+ viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none" } as ViewStyle) },
720
740
  // Open (popover showing): the resting surface plus the 2px active ring — the
721
741
  // one thing that separates open from at-rest, and identical to a focused input.
722
742
  viewActive: {
@@ -26,6 +26,11 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
26
26
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
27
27
  * Omit for a plain label list — both render through the same `OptionList`. */
28
28
  renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
29
+ /** A second line under an option's label in the dropdown — a driver's phone
30
+ * under their name, a code under a description. `OptionList` has always
31
+ * rendered this two-line `MenuListItem`; this only forwards it, which is why
32
+ * callers were hand-rebuilding the anatomy through `renderOptionContent`. */
33
+ getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
29
34
  /** Render ONE selected option as its resting chip — single: the value; multi:
30
35
  * each tag. Falls back to `renderOptionContent`, then the plain label (single) /
31
36
  * a zinc `Badge` (multi). The one seam for "how the selection looks". */
@@ -135,7 +140,7 @@ function InlineSelectShell(props: {
135
140
  }
136
141
 
137
142
  export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
138
- const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
143
+ const { options, renderOptionContent, getOptionDescription, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
139
144
  const labels = useLoticsLocale().inline;
140
145
  const [open, setOpen] = useState(autoFocus);
141
146
  const [saving, setSaving] = useState(false);
@@ -191,6 +196,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
191
196
  >
192
197
  <OptionList<T, true, D>
193
198
  multi
199
+ getOptionDescription={getOptionDescription}
194
200
  search={{ mode: searchMode }}
195
201
  options={options}
196
202
  value={draft}
@@ -237,6 +243,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
237
243
  error={error}
238
244
  >
239
245
  <OptionList<T, false, D>
246
+ getOptionDescription={getOptionDescription}
240
247
  search={{ mode: searchMode }}
241
248
  options={options}
242
249
  value={value}