@lotics/ui 13.9.0 → 13.10.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.
@@ -243,15 +243,23 @@ plain `useState` (controlled `status` everywhere); the family owns the mechanics
243
243
  Undo, the counter, Keep-all, apply gating). Never apply a field with no value — gate unresolved
244
244
  conflicts (`keepDisabled`, `applyDisabled`). Editing IS the review.
245
245
 
246
- ## Ask back — `Clarify`
246
+ ## Ask back — `Clarify` / `ClarifyWizard`
247
247
 
248
248
  `Clarify` (`@lotics/ui/clarify`): when the agent is unsure, it asks a question with quick-reply
249
- options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question`,
250
- `options` (`{ label, value, description? }` — a `ChoiceList`), `onAnswer(value)` resumes the run,
251
- and the controlled `answer` keeps the pick switchable until committed.
252
-
253
- **Limitation:** the "Question" eyebrow above the prompt is currently hardcoded English (no label
254
- prop, not on the locale provider).
249
+ options and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run: `question` (the
250
+ prompt, rendered as-is — no eyebrow chrome), `options` (`{ label, value, description }` — every
251
+ answer carries a REQUIRED one-line description so the human picks without re-deriving it from the
252
+ question; a `ChoiceList`), `onAnswer(value)` resumes the run, the controlled `answer` keeps the pick
253
+ switchable until committed. **`allowCustom`** adds an always-visible, borderless, auto-growing multiline field below the
254
+ options typing it IS the selection; its text becomes the value (any value not matching an option
255
+ reads as the custom answer).
256
+
257
+ `ClarifyWizard` (`@lotics/ui/clarify_wizard`): a SEQUENCE of clarify questions worked one at a time —
258
+ Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebrow ("1 / 3") above the
259
+ question — no progress bar. Each step is a `Clarify`; advance only once the
260
+ current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
261
+ `custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
262
+ locale-wired (the `clarify` slice). The multi-question form of the ask-back.
255
263
 
256
264
  ## Provenance — `Sources`
257
265
 
package/docs/catalog.md CHANGED
@@ -980,11 +980,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
980
980
  `ChangeRecord` (THE item card: registers like a Change; tone wash + localized op word;
981
981
  verb level follows the decision level) · `ChangeBand` (the raw ± band) ·
982
982
  `ChangeValueInput` (the diff-at-rest editor); the `changeReview` locale slice.
983
- - **`clarify`** — `Clarify`: the agent asks back — a borderless block (question + a
984
- `ChoiceList`), no card wrapper.
983
+ - **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
984
+ (the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
985
+ the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires
986
+ `{ value, label, description }` — the description is MANDATORY (every answer explains itself).
987
+ `allowCustom` adds an always-visible borderless multiline custom-answer field below the options.
988
+ - **`clarify_wizard`** — `ClarifyWizard` + `ClarifyWizardQuestion`/`ClarifyWizardAnswer`: a SEQUENCE
989
+ of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator; advance
990
+ only when the current is answered, `onSubmit` returns one `{ value, custom }` per question.
991
+ Navigation chrome is locale-wired (the `clarify` locale slice).
985
992
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
986
993
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
987
- agent's quick-reply surface.
994
+ agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
995
+ text IS the value (a value not in `options` reads as the custom answer).
988
996
  - **`sources`** — `Sources` + `SourceRef`/`SourceKind` (record | document | table | web |
989
997
  knowledge): provenance chips, per-kind glyphs.
990
998
  - **`finding`** — `Finding` + `FindingComparison` + `FindingSeverity`/`FindingLabels`: one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "13.9.0",
3
+ "version": "13.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -89,6 +89,7 @@
89
89
  "./change_review": "./src/change_review.tsx",
90
90
  "./finding": "./src/finding.tsx",
91
91
  "./clarify": "./src/clarify.tsx",
92
+ "./clarify_wizard": "./src/clarify_wizard.tsx",
92
93
  "./choice_list": "./src/choice_list.tsx",
93
94
  "./sources": "./src/sources.tsx",
94
95
  "./icon": "./src/icon.tsx",
@@ -1,10 +1,22 @@
1
- import { Pressable, View } from "react-native";
1
+ import { useState } from "react";
2
+ import { Pressable, TextInput, View } from "react-native";
2
3
  import { colors } from "./colors";
3
4
  import { Text } from "./text";
4
5
  import { Icon } from "./icon";
5
6
  import { Divider } from "./divider";
6
- import { useFocusRing } from "./use_focus_ring";
7
- import { FOCUS_RING } from "./control_surface";
7
+ import { composeHandler, useFocusRing } from "./use_focus_ring";
8
+ import { useHover } from "./use_hover";
9
+ import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
10
+ import { useAutoGrowHeight } from "./use_auto_grow_height";
11
+ import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
12
+ import { useLoticsLocale } from "./locale";
13
+
14
+ // A typed custom answer must read exactly like a picked option's LABEL (`Text
15
+ // size="sm" weight="medium"`). Its SIZE stays on the input contract (getInputTextStyle:
16
+ // 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
17
+ // size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
18
+ // and colour to the label, which is where an unstyled input actually diverges from it.
19
+ const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: -0.4, color: colors.zinc[900] } as const;
8
20
 
9
21
  export interface ChoiceOption {
10
22
  label: string;
@@ -15,25 +27,33 @@ export interface ChoiceOption {
15
27
 
16
28
  export interface ChoiceListProps {
17
29
  options: ChoiceOption[];
18
- /** The chosen value. */
30
+ /** The chosen value — an option's `value`, or (with `allowCustom`) the free
31
+ * TEXT of an "Other" answer. `undefined` = nothing chosen. */
19
32
  value?: string;
20
33
  onSelect: (value: string) => void;
34
+ /** Show an always-visible inline free-text field at the bottom for a custom
35
+ * answer — typing it IS the selection. Its text IS the value (any `value` not
36
+ * matching an option reads as the custom answer, so the caller distinguishes a
37
+ * custom answer by "not in `options`"). */
38
+ allowCustom?: boolean;
21
39
  }
22
40
 
23
41
  /** One selectable option — a role-less-free `Pressable` (its own accessible name
24
42
  * + selected state), hover wash + focus ring, no border. */
25
- function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: (value: string) => void }) {
43
+ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: () => void }) {
26
44
  const { focusVisible, focusProps } = useFocusRing();
27
45
  return (
28
46
  <Pressable
29
47
  accessibilityRole="button"
30
48
  accessibilityLabel={option.label}
31
49
  accessibilityState={{ selected }}
32
- onPress={() => onSelect(option.value)}
50
+ onPress={onSelect}
33
51
  {...focusProps}
34
52
  style={({ hovered, pressed }) => [
35
- { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 6, borderRadius: 8 },
36
- hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
53
+ { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
54
+ // Selected reads as a persistent tint (zinc-100); an unselected row only
55
+ // washes on hover (zinc-50). The check below reserves its slot always.
56
+ selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
37
57
  focusVisible ? { boxShadow: FOCUS_RING } : null,
38
58
  ]}
39
59
  >
@@ -47,28 +67,97 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
47
67
  </Text>
48
68
  ) : null}
49
69
  </View>
50
- {selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
70
+ <View style={{ width: 17, alignItems: "center" }}>
71
+ {selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
72
+ </View>
51
73
  </Pressable>
52
74
  );
53
75
  }
54
76
 
77
+ /** The custom-answer row — the SAME box as a `ChoiceRow` (padding · radius · hover
78
+ * wash · focus ring), but holding an always-editable, auto-growing multiline input
79
+ * instead of a pressable label. Typing it IS the selection; focusing it re-selects a
80
+ * parked draft (`onFocus`). */
81
+ function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
82
+ const { hovered, hoverProps } = useHover();
83
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
84
+ const grow = useAutoGrowHeight({ minLines: 1 });
85
+ return (
86
+ <View
87
+ {...(hoverProps as object)}
88
+ style={[
89
+ { paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
90
+ // Same states as an option row: a filled custom answer reads as selected
91
+ // (zinc-100); otherwise it washes on hover (zinc-50).
92
+ selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
93
+ focusVisible ? { boxShadow: FOCUS_RING } : null,
94
+ ]}
95
+ >
96
+ <TextInput
97
+ ref={grow.inputRef}
98
+ multiline
99
+ value={value}
100
+ onChangeText={(text) => { onChangeText(text); grow.measure(); }}
101
+ onFocus={composeHandler(onFocus, focusProps.onFocus)}
102
+ onBlur={focusProps.onBlur}
103
+ onContentSizeChange={grow.onContentSizeChange}
104
+ scrollEnabled={grow.scrollEnabled}
105
+ placeholder={placeholder}
106
+ placeholderTextColor={colors.zinc[400]}
107
+ // Bare input: the row owns padding + hover + focus ring, so it strips its own
108
+ // padding and browser outline and just grows with the text. Input-contract size +
109
+ // the option-label family/tracking/colour, so a typed answer reads like a picked one.
110
+ style={[getInputTextStyle(), ANSWER_LABEL_FONT, { height: grow.containerHeight, padding: 0, outlineStyle: "none" as unknown as "solid" }]}
111
+ />
112
+ </View>
113
+ );
114
+ }
115
+
55
116
  /**
56
117
  * A vertical set of selectable answer options — divider-separated rows (no
57
118
  * bordered cards), each with its own focus ring + hover wash, single-select and
58
119
  * freely switchable (pick a different option any time). The chosen row shows a
59
- * check. The agent's quick-reply surface (`Clarify` uses it) and any "pick one"
60
- * question.
120
+ * check. With `allowCustom`, an always-visible inline free-text field at the
121
+ * bottom captures a custom answer (typing it IS the selection). The agent's
122
+ * quick-reply surface (`Clarify` / `ClarifyWizard` use it) and any "pick one" question.
61
123
  */
62
124
  export function ChoiceList(props: ChoiceListProps) {
63
- const { options, value, onSelect } = props;
125
+ const { options, value, onSelect, allowCustom } = props;
126
+ const labels = useLoticsLocale().clarify;
127
+ // A value that matches no option IS a custom answer (its own text, or "" right
128
+ // after focusing an empty custom row).
129
+ const isCustomValue = (v: string | undefined): v is string => !!allowCustom && typeof v === "string" && !options.some((o) => o.value === v);
130
+ // The custom TEXT is a draft that outlives selection: picking an option deselects
131
+ // the custom row but must KEEP its text so the human can return to it. The single
132
+ // `value` can't hold both a picked option and the parked text, so the draft is
133
+ // local — seeded from `value` when this row mounts already custom-selected.
134
+ const [customDraft, setCustomDraft] = useState(() => (isCustomValue(value) ? value : ""));
135
+ const customSelected = isCustomValue(value);
64
136
  return (
65
137
  <View>
66
138
  {options.map((o, i) => (
67
139
  <View key={o.value}>
68
- {i > 0 ? <Divider /> : null}
69
- <ChoiceRow option={o} selected={value === o.value} onSelect={onSelect} />
140
+ {/* The hairline carries vertical padding so each row's rounded hover/selected
141
+ fill has air on both sides of it (a flush divider would pinch the corners). */}
142
+ {i > 0 ? <Divider paddingVertical={6} /> : null}
143
+ <ChoiceRow option={o} selected={value === o.value} onSelect={() => onSelect(o.value)} />
70
144
  </View>
71
145
  ))}
146
+ {allowCustom ? (
147
+ <View>
148
+ {options.length > 0 ? <Divider paddingVertical={6} /> : null}
149
+ {/* The custom answer is an ALWAYS-visible inline row (no press-to-reveal). Its
150
+ text persists (customDraft) across option switches; typing selects it, and
151
+ focusing a non-empty parked draft re-selects it without retyping. */}
152
+ <CustomAnswerRow
153
+ value={customDraft}
154
+ selected={customSelected && customDraft.length > 0}
155
+ onChangeText={(text) => { setCustomDraft(text); onSelect(text); }}
156
+ onFocus={() => { if (customDraft.length > 0 && !customSelected) onSelect(customDraft); }}
157
+ placeholder={labels.otherPlaceholder}
158
+ />
159
+ </View>
160
+ ) : null}
72
161
  </View>
73
162
  );
74
163
  }
package/src/clarify.tsx CHANGED
@@ -1,38 +1,54 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "./text";
3
- import { ChoiceList, type ChoiceOption } from "./choice_list";
3
+ import { ChoiceList } from "./choice_list";
4
4
 
5
- export type ClarifyOption = ChoiceOption;
5
+ export interface ClarifyOption {
6
+ value: string;
7
+ label: string;
8
+ /** REQUIRED for a clarify answer: the one-line "what this choice means" so the
9
+ * human can pick without re-deriving it from the question. */
10
+ description: string;
11
+ }
6
12
 
7
13
  export interface ClarifyProps {
8
14
  /** The agent's question — what it needs settled to proceed. */
9
15
  question: string;
16
+ /** A muted eyebrow rendered tight above the question — e.g. a wizard's "1 / 3". */
17
+ eyebrow?: string;
10
18
  options: ClarifyOption[];
11
19
  onAnswer: (value: string) => void;
12
- /** The chosen option's VALUE — the picked choice shows selected; it stays
13
- * switchable so the human can change their mind. */
20
+ /** The chosen option's VALUE — or, with `allowCustom`, a custom answer's free
21
+ * text. The picked choice shows selected; switchable so the human can change. */
14
22
  answer?: string;
23
+ /** Offer an "Other…" free-text row below the options. */
24
+ allowCustom?: boolean;
15
25
  }
16
26
 
17
27
  /**
18
- * The agent asks BACK — a question with selectable answer options the human picks
19
- * before the run continues, freely switchable until committed. A borderless block
20
- * (no card): the question, then divider-separated option rows (`ChoiceList`).
21
- * Human-in-the-loop input: when the agent is unsure, it clarifies instead of
22
- * guessing wrong. Pair with `AgentRun` / `AgentProgress`.
28
+ * The agent asks BACK — a question with selectable answer options (each carrying
29
+ * a one-line description) the human picks before the run continues, freely
30
+ * switchable until committed. A borderless block (no card): the question text,
31
+ * then divider-separated option rows (`ChoiceList`); `allowCustom` adds an
32
+ * "Other…" free-text row. For a SEQUENCE of questions with Back/Next/Submit
33
+ * navigation, use `ClarifyWizard`. Human-in-the-loop: when unsure, the agent
34
+ * clarifies instead of guessing wrong. Pair with `AgentRun` / `AgentProgress`.
23
35
  */
24
36
  export function Clarify(props: ClarifyProps) {
25
37
  return (
26
- <View style={{ gap: 14 }}>
27
- <View style={{ gap: 4 }}>
28
- <Text size="xs" color="muted" weight="medium">
29
- Question
30
- </Text>
38
+ <View style={{ gap: 12 }}>
39
+ {/* paddingHorizontal 8 aligns the eyebrow + question with the option/answer
40
+ text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
41
+ <View style={{ gap: 4, paddingHorizontal: 8 }}>
42
+ {props.eyebrow ? (
43
+ <Text size="xs" color="muted" weight="medium">
44
+ {props.eyebrow}
45
+ </Text>
46
+ ) : null}
31
47
  <Text size="sm" weight="medium">
32
48
  {props.question}
33
49
  </Text>
34
50
  </View>
35
- <ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} />
51
+ <ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} allowCustom={props.allowCustom} />
36
52
  </View>
37
53
  );
38
54
  }
@@ -0,0 +1,84 @@
1
+ import { useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Clarify, type ClarifyOption } from "./clarify";
5
+ import { useLoticsLocale } from "./locale";
6
+
7
+ export interface ClarifyWizardQuestion {
8
+ question: string;
9
+ answers: ClarifyOption[];
10
+ /** Offer an "Other…" free-text answer for this question. */
11
+ allowCustom?: boolean;
12
+ }
13
+
14
+ export interface ClarifyWizardAnswer {
15
+ /** The chosen option's value, or the custom free text. */
16
+ value: string;
17
+ /** True when the value came from the "Other…" row (not one of the options). */
18
+ custom: boolean;
19
+ }
20
+
21
+ export interface ClarifyWizardProps {
22
+ questions: ClarifyWizardQuestion[];
23
+ /** Fires when every question is answered and the human submits — one answer
24
+ * per question, aligned by index. */
25
+ onSubmit: (answers: ClarifyWizardAnswer[]) => void;
26
+ onCancel: () => void;
27
+ }
28
+
29
+ /**
30
+ * A SEQUENCE of `Clarify` questions the human works through one at a time —
31
+ * Back / Next / Cancel / Submit with a position indicator. Each step reuses
32
+ * `Clarify` (question + described answers + optional "Other…"). You can only
33
+ * advance once the current question is answered; Submit fires when the last is.
34
+ * The multi-question form of the agent's ask-back; single questions use `Clarify`.
35
+ */
36
+ export function ClarifyWizard(props: ClarifyWizardProps) {
37
+ const { questions, onSubmit, onCancel } = props;
38
+ const labels = useLoticsLocale().clarify;
39
+ const [index, setIndex] = useState(0);
40
+ const [answers, setAnswers] = useState<(string | undefined)[]>(() => questions.map(() => undefined));
41
+
42
+ const total = questions.length;
43
+ const q = questions[index];
44
+ const current = answers[index];
45
+ // Answered = a non-empty string: an option value, or filled custom text ("" is
46
+ // "Other" picked but not yet typed — not answered).
47
+ const answered = typeof current === "string" && current.length > 0;
48
+ const isLast = index === total - 1;
49
+
50
+ const setAnswer = (v: string) =>
51
+ setAnswers((prev) => {
52
+ const next = [...prev];
53
+ next[index] = v;
54
+ return next;
55
+ });
56
+
57
+ const submit = () =>
58
+ onSubmit(
59
+ questions.map((question, i) => {
60
+ const value = answers[i] ?? "";
61
+ return { value, custom: !question.answers.some((a) => a.value === value) };
62
+ }),
63
+ );
64
+
65
+ return (
66
+ <View style={{ gap: 16 }}>
67
+ {/* The step position is an eyebrow above the question (no progress bar); the
68
+ footer aligns to the same 8px inset as the question and answers. Keyed by
69
+ index so each step's custom-answer draft is its own (no bleed across steps). */}
70
+ <Clarify key={index} eyebrow={`${index + 1} / ${total}`} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
71
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
72
+ <Button title={labels.cancel} color="muted" onPress={onCancel} />
73
+ <View style={{ flexDirection: "row", gap: 8 }}>
74
+ {index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
75
+ {isLast ? (
76
+ <Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
77
+ ) : (
78
+ <Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
79
+ )}
80
+ </View>
81
+ </View>
82
+ </View>
83
+ );
84
+ }
package/src/locale.tsx CHANGED
@@ -57,6 +57,8 @@ export interface LoticsLocale {
57
57
  /** The `Inline*` editor family: the shared save-error line and the
58
58
  * text-editor Save/Cancel tooltips. */
59
59
  inline: { saveError: string; save: string; cancel: string };
60
+ /** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
61
+ clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
60
62
  /** `Ledger`: the screen-reader name of a peekable row. */
61
63
  ledger: { rowDetails: (label: string) => string };
62
64
  /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
@@ -159,6 +161,7 @@ export const en: LoticsLocale = {
159
161
  dangerZone: { title: "Danger zone" },
160
162
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
161
163
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
164
+ clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
162
165
  ledger: { rowDetails: (label) => `${label} details` },
163
166
  sectionHeading: { info: "About this data" },
164
167
  chip: { remove: "Remove" },
@@ -254,6 +257,7 @@ export const vi: LoticsLocale = {
254
257
  dangerZone: { title: "Vùng nguy hiểm" },
255
258
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
256
259
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
260
+ clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
257
261
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
258
262
  sectionHeading: { info: "Giải thích dữ liệu" },
259
263
  chip: { remove: "Xóa" },
package/src/table.tsx CHANGED
@@ -315,7 +315,11 @@ const styles = StyleSheet.create({
315
315
  // Divider-separated.
316
316
  headerBand: {
317
317
  paddingHorizontal: ROW_GUTTER,
318
- paddingVertical: 10,
318
+ // NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
319
+ // the top spacing (a Section's gap, a Card's padding, any `gap` above). A band
320
+ // paddingTop would stack on that and double-gap the column header; the bottom
321
+ // padding is the band's own — it spaces the header off the hairline below.
322
+ paddingBottom: 10,
319
323
  flexDirection: "row",
320
324
  alignItems: "center",
321
325
  gap: COLUMN_GAP,