@lotics/ui 27.17.0 → 28.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.17.0",
3
+ "version": "28.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -93,6 +93,7 @@
93
93
  "./agent_progress": "./src/agent_progress.tsx",
94
94
  "./approval_prompt": "./src/approval_prompt.tsx",
95
95
  "./message_actions": "./src/message_actions.tsx",
96
+ "./comments_button": "./src/comments_button.tsx",
96
97
  "./copy_button": "./src/copy_button.tsx",
97
98
  "./copy_text": {
98
99
  "react-native": "./src/copy_text.ts",
@@ -176,6 +177,7 @@
176
177
  "./form_field": "./src/form_field.tsx",
177
178
  "./checkbox_input": "./src/checkbox_input.tsx",
178
179
  "./check_circle": "./src/check_circle.tsx",
180
+ "./checklist": "./src/checklist.tsx",
179
181
  "./radio_picker": "./src/radio_picker.tsx",
180
182
  "./form_text_input": "./src/form_text_input.tsx",
181
183
  "./form_switch": "./src/form_switch.tsx",
@@ -194,7 +196,6 @@
194
196
  "./detail_row": "./src/detail_row.tsx",
195
197
  "./record_summary": "./src/record_summary.tsx",
196
198
  "./capture_row": "./src/capture_row.tsx",
197
- "./task": "./src/task.tsx",
198
199
  "./suggestion_chip": "./src/suggestion_chip.tsx",
199
200
  "./danger_zone": "./src/danger_zone.tsx",
200
201
  "./scan_field": "./src/scan_field.tsx",
@@ -216,7 +217,6 @@
216
217
  "./card": "./src/card.tsx",
217
218
  "./accordion": "./src/accordion.tsx",
218
219
  "./stepper": "./src/stepper.tsx",
219
- "./pipeline": "./src/pipeline.tsx",
220
220
  "./step_progress": "./src/step_progress.tsx",
221
221
  "./tabs": "./src/tabs.tsx",
222
222
  "./segmented_control": "./src/segmented_control.tsx",
@@ -0,0 +1,339 @@
1
+ import { type ReactNode } from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { TextLink } from "./text_link";
5
+ import { Stepper, Step, type StepPositional } from "./stepper";
6
+ import { useLoticsLocale } from "./locale";
7
+
8
+ /**
9
+ * THE CHECKLIST — an ordered run of rows a reader ticks off, optionally grouped
10
+ * into phases, optionally strung on a connecting spine.
11
+ *
12
+ * It replaces two families that had converged on one anatomy. `task` modelled N
13
+ * items ticked in any order; `pipeline` modelled ONE thing walking N positions.
14
+ * That distinction is real about the DATA and turned out to be invisible in the
15
+ * COMPONENT: both ended up as a ring, a title, a value beside it, a muted note,
16
+ * a detail block and an optional grouping level — and the proof is that a single
17
+ * afternoon added `trailing`/`meta` to `TaskTitle` and, separately, the same two
18
+ * to `PipelineStep`, then `tone` to `TaskCaption` and `tone`+`action` to
19
+ * `PipelineNote`. One feature, implemented twice, because the surfaces were
20
+ * twins. What actually differed was a LINE between the rings, which is a mode,
21
+ * and the kit's own rule is that a mode is a variant rather than a second
22
+ * component.
23
+ *
24
+ * The ORDERING lives in the caller's model, not here. Whether ticking rung 4
25
+ * implies 1–3, whether un-ticking walks the record back, what a tick even MEANS
26
+ * (reached, or stamped) — only the caller's data knows. This renders rows.
27
+ *
28
+ * ```tsx
29
+ * <Checklist connected>
30
+ * <ChecklistGroup title="Sales" />
31
+ * <ChecklistItem title="Quote sent" done onToggle={…} trailing={<DateStamp …/>}>
32
+ * <ChecklistNote tone="warning" action={{ label: "Set in General", onPress }}>
33
+ * Nobody is assigned to this desk.
34
+ * </ChecklistNote>
35
+ * <ChecklistActions><Button title="Hand off" /></ChecklistActions>
36
+ * </ChecklistItem>
37
+ * <ChecklistGroup title="Operations" />
38
+ * <ChecklistItem title="Collected" onToggle={…} />
39
+ * </Checklist>
40
+ * ```
41
+ *
42
+ * Groups and items are SIBLINGS, not nested. One run, one spine, one ring column,
43
+ * one text edge — a group is simply a row that carries a name and no control.
44
+ * Nesting them put the group's own lines on a different x from its items' and
45
+ * needed two containers to reconcile.
46
+ */
47
+ export interface ChecklistProps {
48
+ children?: ReactNode;
49
+ /**
50
+ * String the rings on a connecting line — for a run whose rows are POSITIONS
51
+ * (one record walking a process), where the line says "these are sequential".
52
+ * Off for a set of independent items, where a line would claim an order the
53
+ * work does not have.
54
+ */
55
+ connected?: boolean;
56
+ /**
57
+ * What the mark MEANS: `check` (default) for rows someone COMPLETES — a ring —
58
+ * and `select` for rows someone PICKS, which wear the square box selection
59
+ * wears everywhere else in the kit.
60
+ *
61
+ * A document picker is the case: "produce this form" is a choice, not work
62
+ * carried out, and a filled RING there reads as already done to anyone who has
63
+ * learned what a ring means. Same anatomy, same gutter; only the mark differs.
64
+ */
65
+ mark?: "check" | "select";
66
+ /** Accent ink for reached rings + the line. Defaults to neutral. */
67
+ color?: string;
68
+ accessibilityLabel?: string;
69
+ }
70
+
71
+ export function Checklist({ children, connected = true, mark = "check", color, accessibilityLabel }: ChecklistProps) {
72
+ return (
73
+ <Stepper
74
+ orientation="vertical"
75
+ connected={connected}
76
+ mark={mark === "select" ? "box" : "ring"}
77
+ color={color}
78
+ gap={CHECKLIST_ROW_GAP}
79
+ accessibilityLabel={accessibilityLabel}
80
+ >
81
+ {children}
82
+ </Stepper>
83
+ );
84
+ }
85
+
86
+ /** Between rows. Tight, because these are one-line rows rather than stages
87
+ * carrying bodies: at a group's rhythm the line between two of them stretches
88
+ * until it reads as a dash instead of a spine. */
89
+ const CHECKLIST_ROW_GAP = 8;
90
+ /** Above a group heading — the air that makes it a heading rather than the last
91
+ * row's caption. Larger than the row gap on purpose; the two are a pair. */
92
+ const CHECKLIST_GROUP_TOP = 18;
93
+ /** Under a group heading. A little more than rows give each other (8) — enough
94
+ * that the name reads as heading all of them rather than belonging to the first,
95
+ * and no more: the heading and its first row are one block, so air between them
96
+ * works against the grouping it is there to state. */
97
+ const CHECKLIST_GROUP_BOTTOM = 4;
98
+
99
+ export interface ChecklistGroupProps extends StepPositional {
100
+ /** The phase's name. */
101
+ title: string;
102
+ /**
103
+ * Whether this phase's rows are showing. CONTROLLED — and deliberately: who may
104
+ * be open at once is the CALLER's rule (one at a time, several, or the live one
105
+ * by default), and a component-local default would silently pick one. The group
106
+ * does not hide its own rows either: they are its SIBLINGS in the run, so the
107
+ * caller simply does not render them.
108
+ */
109
+ open?: boolean;
110
+ /** Show the disclosure — a small pressable text beside the name. Omit for a
111
+ * phase that is always open. */
112
+ onToggleOpen?: () => void;
113
+ /** Override the disclosure's wording; otherwise the locale's. */
114
+ labels?: { expand?: string; collapse?: string };
115
+ }
116
+
117
+ /**
118
+ * A PHASE — a name over the rows that follow it, and nothing else.
119
+ *
120
+ * OPTIONAL: a flat checklist renders none, and one run may group only part of
121
+ * itself. It carries no control, because a phase owns no completion of its own —
122
+ * its rows do. A ring here would be a control that never responds, and one
123
+ * identical to the pressable rings under it invites the press it will refuse,
124
+ * which is worse than showing nothing: the reader clicks and the product says
125
+ * nothing back.
126
+ *
127
+ * It carries no BODY either. A condition or an act hung off a heading leaves the
128
+ * reader working out which of the rows beneath it they were about; on the row
129
+ * that owes the work there is no ambiguity.
130
+ *
131
+ * TYPE: `sm`, medium, full ink — the treatment a BAND heading already wears
132
+ * elsewhere on a record (a billing group, a ledger band). One surface should not
133
+ * teach two looks for the same job, and a reader who has learned that this is
134
+ * what "a named group of rows" looks like should not have to learn it again a
135
+ * section later.
136
+ *
137
+ * It was briefly an uppercase muted eyebrow, on the reasoning that a heading at
138
+ * the row's own `sm` medium is indistinguishable from the `sm` medium a CURRENT
139
+ * row wears. True of the TYPE alone, and irrelevant here: a group carries no
140
+ * ring and sits on the ring column's own left edge, while every row is ringed and
141
+ * indented past it. The structure already separates them, so spending case and
142
+ * ink on saying it again only made the label quieter than the prose it organises
143
+ * — which inverts the hierarchy it exists to state.
144
+ */
145
+ export function ChecklistGroup({ title, open, onToggleOpen, labels, ...positional }: ChecklistGroupProps) {
146
+ const locale = useLoticsLocale();
147
+ const word = open ? labels?.collapse ?? locale.checklist.collapse : labels?.expand ?? locale.checklist.expand;
148
+ return (
149
+ <Step status="upcoming" marker={false} {...positional}>
150
+ <View
151
+ style={{
152
+ paddingTop: positional._first ? 0 : CHECKLIST_GROUP_TOP,
153
+ // A closed phase needs no air beneath it — the space belongs to the rows
154
+ // it is heading, and kept when there are none it reads as a gap the
155
+ // reader looks into for something that is not there.
156
+ paddingBottom: onToggleOpen != null && !open ? 0 : CHECKLIST_GROUP_BOTTOM,
157
+ flexDirection: "row",
158
+ alignItems: "baseline",
159
+ gap: 10,
160
+ }}
161
+ >
162
+ <Text size="sm" weight="medium">
163
+ {title}
164
+ </Text>
165
+ {/* A TEXT link, not a chevron: the phase is a heading rather than a row, so
166
+ a disclosure triangle would give it the affordance of something that
167
+ opens INTO a place. This only shows and hides rows already in the run,
168
+ and it says which in words. */}
169
+ {onToggleOpen != null ? (
170
+ <Pressable onPress={onToggleOpen} accessibilityRole="button" accessibilityLabel={`${word} · ${title}`}>
171
+ <TextLink size="xs">{word}</TextLink>
172
+ </Pressable>
173
+ ) : null}
174
+ </View>
175
+ </Step>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Declares that this component renders a markerless row, so `Stepper` can END the
181
+ * line above it rather than running one into a column that draws nothing.
182
+ *
183
+ * A STATIC on the component, not a guess about its name or its rendered output:
184
+ * the run is built from siblings, so the only thing that can know a heading is
185
+ * coming is the heading's own type, and it says so itself.
186
+ */
187
+ ChecklistGroup.markerless = true as const;
188
+
189
+ export interface ChecklistItemProps extends StepPositional {
190
+ /** The row's name. Doubles as the ring's accessible name — the ring carries no
191
+ * visible label of its own, so the text rides beside it. */
192
+ title: string;
193
+ /** Ticked. */
194
+ done?: boolean;
195
+ /** The row the reader is waiting on — medium weight, so a run full of ticks
196
+ * still says which one is owed. */
197
+ current?: boolean;
198
+ /** Tick / un-tick. Omit for a run that only reports. */
199
+ onToggle?: (done: boolean) => void;
200
+ /**
201
+ * ONE value on the title's row — in practice the row's own STAMP (a
202
+ * `DateStamp`), which is how a milestone gets BACKDATED. The tick cannot: it
203
+ * writes today, and a run filled in after the fact is full of days that already
204
+ * passed.
205
+ *
206
+ * The row's OWN value only. A field with a home elsewhere — an assignee, an
207
+ * address, a portal login that a section already owns — is reported by a
208
+ * `ChecklistNote` naming the gap, never edited here: a list that also collects
209
+ * gives every row two modes and asks the reader which one they are in.
210
+ */
211
+ trailing?: ReactNode;
212
+ /** SHORT muted text after `trailing` — how long this row has been owed, who it
213
+ * waits on. Two words; prose that grows is a `ChecklistNote`, which owns its
214
+ * own line. */
215
+ meta?: string;
216
+ /** The row's overflow — an `ActionMenu`, at the end of its title row. */
217
+ menu?: ReactNode;
218
+ /** The row's own notes, acts and detail. */
219
+ children?: ReactNode;
220
+ }
221
+
222
+ /** ONE ROW — the level that carries the ticks. */
223
+ export function ChecklistItem({
224
+ title,
225
+ done,
226
+ current,
227
+ onToggle,
228
+ trailing,
229
+ meta,
230
+ menu,
231
+ children,
232
+ ...positional
233
+ }: ChecklistItemProps) {
234
+ return (
235
+ <Step
236
+ status={done ? "done" : current ? "current" : "upcoming"}
237
+ onToggle={onToggle}
238
+ accessibilityLabel={title}
239
+ {...positional}
240
+ >
241
+ <View style={{ gap: 6 }}>
242
+ {/* BASELINE, not centre: the title is `sm` and the meta `xs`, and two
243
+ sizes centred in one row share no baseline — the smaller floats.
244
+ WRAPS, so a long title drops its stamp to the next line rather than
245
+ crushing it. */}
246
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
247
+ <Text size="sm" weight={current ? "medium" : "regular"} color={done ? "muted" : "default"}>
248
+ {title}
249
+ </Text>
250
+ {trailing}
251
+ {meta ? (
252
+ <Text size="xs" color="muted">
253
+ {meta}
254
+ </Text>
255
+ ) : null}
256
+ {menu != null ? <View style={{ marginLeft: "auto" }}>{menu}</View> : null}
257
+ </View>
258
+ {children != null ? <View style={{ gap: 8 }}>{children}</View> : null}
259
+ </View>
260
+ </Step>
261
+ );
262
+ }
263
+
264
+ export interface ChecklistNoteProps {
265
+ children: ReactNode;
266
+ /** `warning` for something outstanding, `danger` for a refusal, `muted` for a
267
+ * plain remark. */
268
+ tone?: "muted" | "warning" | "danger";
269
+ /**
270
+ * Where the reader goes to resolve it — the section that OWNS the missing
271
+ * value, jumped to by name.
272
+ *
273
+ * This is the whole answer to "that value is not set". A checklist REPORTS: it
274
+ * does not collect, and it does not PREVIEW. An editor here is a second edit
275
+ * surface for one fact; a rendered value — an avatar, a badge — is a copy that
276
+ * has to be kept in step with the field's real home, and readers start treating
277
+ * the list as the place the value lives. Naming the gap and handing over the
278
+ * navigation is the complete job.
279
+ */
280
+ action?: { label: string; onPress: () => void };
281
+ }
282
+
283
+ /** A CONDITION on one row, in words. Its own line, never inline: two words that
284
+ * ride the title belong in `ChecklistItem.meta`, and prose that grows would wrap
285
+ * into the title. */
286
+ export function ChecklistNote({ children, tone = "muted", action }: ChecklistNoteProps) {
287
+ const text = (
288
+ <Text size="xs" color={tone}>
289
+ {children}
290
+ </Text>
291
+ );
292
+ if (!action) return text;
293
+ return (
294
+ <View style={{ flexDirection: "row", alignItems: "baseline", flexWrap: "wrap", columnGap: 6 }}>
295
+ {text}
296
+ <Pressable onPress={action.onPress} accessibilityRole="button" accessibilityLabel={action.label}>
297
+ <TextLink size="xs">{action.label}</TextLink>
298
+ </Pressable>
299
+ </View>
300
+ );
301
+ }
302
+
303
+ /** The act(s) that complete or leave this row. Wraps on a narrow column so a row
304
+ * with two verbs never pushes the run sideways. */
305
+ export function ChecklistActions({ children }: { children: ReactNode }) {
306
+ return <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>{children}</View>;
307
+ }
308
+
309
+ export interface ChecklistFieldProps {
310
+ /** The value's name, stacked ABOVE the control: the row's content column is
311
+ * already indented past the ring, and a second fixed column inside it leaves
312
+ * nothing for the value. */
313
+ label?: string;
314
+ children: ReactNode;
315
+ /** Cap the control so a lone text input does not run the full column. */
316
+ maxWidth?: number;
317
+ }
318
+
319
+ /**
320
+ * A value the ROW itself owns, editable in place — and the narrow exception to
321
+ * "a checklist reports".
322
+ *
323
+ * Reach for it only when the value has NO OTHER HOME: a portal login created at
324
+ * this step and existing on no other surface would otherwise be unreachable the
325
+ * moment the row is behind you. Anything a section already owns is that section's
326
+ * to collect, and this row's `ChecklistNote` names the gap instead.
327
+ */
328
+ export function ChecklistField({ label, children, maxWidth = 320 }: ChecklistFieldProps) {
329
+ return (
330
+ <View style={{ gap: 2, maxWidth }}>
331
+ {label ? (
332
+ <Text size="xs" color="muted">
333
+ {label}
334
+ </Text>
335
+ ) : null}
336
+ {children}
337
+ </View>
338
+ );
339
+ }
@@ -0,0 +1,108 @@
1
+ import { StyleSheet, type GestureResponderEvent } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Icon } from "./icon";
4
+ import { PressableHighlight } from "./pressable_highlight";
5
+ import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
6
+ import { Text } from "./text";
7
+ import { useLoticsLocale } from "./locale";
8
+
9
+ export interface CommentsButtonProps {
10
+ /** How many comments the thread holds. Render the button only where there ARE
11
+ * some — see the note on zero below. */
12
+ count: number;
13
+ /** What the thread is about — the record's name. It only reaches the accessible
14
+ * label, which is where a screen-reader user needs it: "3 comments on Northwind
15
+ * Packaging" is a destination, "3 comments" on the fortieth row is not. */
16
+ subject?: string;
17
+ /** Open the discussion. A count the reader cannot act on states that a
18
+ * conversation exists and leaves them to find it. */
19
+ onPress?: (event: GestureResponderEvent) => void;
20
+ /** `sm` (the register row's) or `md`. Matches `CopyButton`'s scale so the two
21
+ * affordances a row carries sit at one size. */
22
+ size?: "sm" | "md";
23
+ /** Override the wording; otherwise the locale's. */
24
+ labels?: { comments?: string; comment?: string };
25
+ disabled?: boolean;
26
+ testID?: string;
27
+ }
28
+
29
+ /**
30
+ * A row's DISCUSSION — the count, the bubble, and the way in.
31
+ *
32
+ * Reach for it wherever a record is listed and its thread would otherwise be
33
+ * invisible until opened: a register row, a card, a compact header. It is the
34
+ * peer of `CopyButton` — the other thing a dense row lets you press — and shares
35
+ * its surface, its scale and its focus ring, so a row carrying both reads as one
36
+ * grammar rather than two authors.
37
+ *
38
+ * **ONE control, not a number beside a button.** The count and the glyph are the
39
+ * same target: the number IS what the reader aims at ("three comments" is the
40
+ * thing they saw), and splitting them leaves half the affordance dead under the
41
+ * pointer. It was briefly an `IconButton` with the count as loose text — which
42
+ * gave the glyph a hover state and the number none.
43
+ *
44
+ * **Full ink.** Everything else on a register row is a value ABOUT the record;
45
+ * this is people talking about it, and it is the one thing on the row that can be
46
+ * UNREAD. Muted, it files itself with the phone number.
47
+ *
48
+ * **Render it only when `count > 0`.** A zero on every quiet row is a column of
49
+ * noise that trains the eye to skip exactly where the signal will appear. The
50
+ * component does not decide that for you — an empty-state row may legitimately
51
+ * want a way to START a thread — but a register should not.
52
+ */
53
+ export function CommentsButton(props: CommentsButtonProps) {
54
+ const { count, subject, onPress, size = "sm", labels, disabled, testID } = props;
55
+ const locale = useLoticsLocale();
56
+ const noun = count === 1 ? labels?.comment ?? locale.commentsButton.comment : labels?.comments ?? locale.commentsButton.comments;
57
+ // The subject rides the NAME, never the visible label: a row already says whose
58
+ // record it is, and repeating it beside the count spends the row's width on
59
+ // something the eye has just read.
60
+ const name = subject ? `${count} ${noun} · ${subject}` : `${count} ${noun}`;
61
+
62
+ return (
63
+ <PressableHighlight
64
+ focusRing
65
+ testID={testID}
66
+ tooltip={name}
67
+ accessibilityRole="button"
68
+ accessibilityLabel={name}
69
+ onPress={onPress}
70
+ disabled={disabled || onPress == null}
71
+ // The row it rides hovers to `zinc[100]`, so this takes the row-control
72
+ // wash a step darker — matching `IconButton`, so the two things a dense row
73
+ // lets you press respond identically.
74
+ style={(state: { hovered?: boolean; pressed?: boolean }) => [
75
+ styles.button,
76
+ styles[size],
77
+ { backgroundColor: state.pressed ? ROW_CONTROL_PRESS : state.hovered ? ROW_CONTROL_HOVER : undefined },
78
+ disabled && styles.disabled,
79
+ ]}
80
+ // The same 40px touch target `IconButton` reserves at these sizes, without
81
+ // the visual box growing into the row.
82
+ hitSlop={size === "sm" ? 8 : 6}
83
+ >
84
+ <Icon size={size === "sm" ? 14 : 16} name="message-square" color={colors.zinc[900]} />
85
+ <Text size="xs" weight="medium" tabular>
86
+ {count}
87
+ </Text>
88
+ </PressableHighlight>
89
+ );
90
+ }
91
+
92
+ const styles = StyleSheet.create({
93
+ button: {
94
+ flexDirection: "row",
95
+ alignItems: "center",
96
+ // Tight: the glyph and its number are ONE word, not two things in a row.
97
+ gap: 3,
98
+ // A pill rather than `IconButton`'s circle — it holds two glyphs' worth of
99
+ // content, and a circle sized to fit them would balloon beside a 24px copy
100
+ // control on the same line.
101
+ borderRadius: 999,
102
+ },
103
+ // 24 at `sm` — the SAME box `IconButton` reserves there, so a row carrying
104
+ // both lands them on one baseline and one height.
105
+ sm: { paddingHorizontal: 6, height: 24 },
106
+ md: { paddingHorizontal: 8, height: 28 },
107
+ disabled: { opacity: 0.3 },
108
+ });
@@ -56,6 +56,19 @@ export const CONTROL_TEXT_INSET = 9;
56
56
  * `CONTROL_TRANSITION` so the change eases in. */
57
57
  export const HOVER_BORDER = colors.zinc[400];
58
58
 
59
+ /**
60
+ * The wash a control paints when it rides a HOVERABLE surface — a register row,
61
+ * a list item, a card.
62
+ *
63
+ * One step darker than the generic hover, and it has to be. A row's own hover is
64
+ * `zinc[100]`; a control that also hovers to `zinc[100]` paints the same grey on
65
+ * the same grey the moment the pointer is over both — which is always, since
66
+ * reaching the control means crossing the row. The control then reads as dead
67
+ * exactly when the reader is pointing at it, and no amount of size fixes that.
68
+ */
69
+ export const ROW_CONTROL_HOVER = colors.zinc[200];
70
+ export const ROW_CONTROL_PRESS = colors.zinc[300];
71
+
59
72
  /** The ease applied to a bordered control's interactive edges (border + ring), so
60
73
  * hover/focus transitions read smooth, not snapped. Web-only; ignored on native. */
61
74
  export const CONTROL_TRANSITION = {
@@ -18,7 +18,7 @@ interface DetailTableColumns {
18
18
  const DetailTableContext = createContext<DetailTableColumns | null>(null);
19
19
 
20
20
  /** THE kit's label column — ~18 characters, where real field names live. It is
21
- * this table's default AND a `TaskList`'s, so a record's fields and a task's
21
+ * this table's default, so a record's fields and a checklist's
22
22
  * read as one language on a surface carrying both; that only holds while the
23
23
  * two are ONE number. A name longer than the column WRAPS inside it: the
24
24
  * column belongs to the SURFACE, so no one field's name may push its own value
@@ -158,7 +158,7 @@ export function DetailRow(props: DetailRowProps) {
158
158
  );
159
159
  // THE field-annotation anatomy — severity, tones, a11y and the control's own
160
160
  // text inset, defined once in `field_annotations` and rendered identically by
161
- // a task's `TaskSubRow`. A record's field and a task's field are the same
161
+ // a checklist row's field. A record's field and a checklist's are the same
162
162
  // thing on two surfaces; they may not say a fault two different ways.
163
163
  const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
164
164
  if (table?.stacked) {
@@ -4,7 +4,7 @@ import { Text } from "./text";
4
4
 
5
5
  /**
6
6
  * What a FIELD says about itself under its own value — ONE anatomy, shared by every field
7
- * surface in the kit (`DetailRow` on a record, `TaskSubRow` on a task). A record's field and a
7
+ * surface in the kit `DetailRow` on a record, `ChecklistField` on a checklist row. A record's field and a
8
8
  * task's field carry the same three tones, in the same order, with the same a11y, because they
9
9
  * ARE the same thing on two surfaces: a name, a value the reader sets, and whatever has to be
10
10
  * said about it. Where the two disagreed, an app with a field-level fault had to demote it into
package/src/icon.tsx CHANGED
@@ -9,8 +9,18 @@
9
9
  * (find the file name via: grep "IconName" node_modules/lucide-react-native/dist/esm/lucide-react-native.js)
10
10
  * 2. Add the icon to the iconComponents object with kebab-case key
11
11
  * The type will be automatically derived from the object keys.
12
+ *
13
+ * The reference below is load-bearing, not tidiness: lucide ships these icon
14
+ * files as JS with no `.d.ts` and does not list them in its `exports`, so the
15
+ * ambient declaration beside this file is the only thing that types them. An
16
+ * ambient file joins a program only when the program INCLUDES it, and a
17
+ * consumer's `include` covers the consumer's own directory — never ours. Every
18
+ * consumer therefore has to reach it through the file that needs it, which is
19
+ * this one.
12
20
  */
13
21
 
22
+ /// <reference path="./lucide-react-native.d.ts" />
23
+
14
24
  import { View } from "react-native";
15
25
  import Activity from "lucide-react-native/dist/esm/icons/activity";
16
26
  import TextAlignCenter from "lucide-react-native/dist/esm/icons/text-align-center";
@@ -2,6 +2,7 @@ import { ActivityIndicator, GestureResponderEvent, StyleProp, StyleSheet, View,
2
2
  import { Icon, IconName } from "./icon";
3
3
  import { colors } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
+ import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
5
6
  import { type ButtonColor } from "./button";
6
7
  import { getButtonIconColor } from "./button_colors";
7
8
  import { Ref } from "react";
@@ -80,7 +81,22 @@ export function IconButton(props: IconButtonProps) {
80
81
  tooltipSide={tooltipSide}
81
82
  accessibilityRole="button"
82
83
  accessibilityLabel={accessibilityLabel ?? tooltip}
83
- style={[styles.button, styles[size], styles[color], elevated && styles.elevated, disabledOrLoading && styles.disabled, style]}
84
+ // A glyph-only control rides ROWS more often than it stands alone, and a
85
+ // row hovers to `zinc[100]` — so the generic hover would paint the same
86
+ // grey on the same grey the moment the pointer reaches this, which is
87
+ // always. The colored variants keep their own fill; only `none`/`white`,
88
+ // which have no fill to darken, take the row-control wash.
89
+ style={(state: { hovered?: boolean; pressed?: boolean }) => [
90
+ styles.button,
91
+ styles[size],
92
+ styles[color],
93
+ elevated && styles.elevated,
94
+ color === "none" || color === "white"
95
+ ? { backgroundColor: state.pressed ? ROW_CONTROL_PRESS : state.hovered ? ROW_CONTROL_HOVER : undefined }
96
+ : null,
97
+ disabledOrLoading && styles.disabled,
98
+ style,
99
+ ]}
84
100
  onPress={handlePress}
85
101
  disabled={disabledOrLoading}
86
102
  // 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
package/src/locale.tsx CHANGED
@@ -69,10 +69,14 @@ export interface LoticsLocale {
69
69
  /** `Ledger`: the screen-reader name of a peekable row. */
70
70
  ledger: { rowDetails: (label: string) => string };
71
71
  /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
72
- /** `Step`/`PipelineStage`'s toggleable MARKER, when the caller names no
72
+ /** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
73
73
  * label of its own. A pipeline stage always passes its title, so this is the
74
74
  * bare-`Step` fallback. */
75
75
  stepper: { complete: string; progress: string };
76
+ /** `ChecklistGroup`'s disclosure — the small pressable text that shows or hides
77
+ * a phase's rows. The kit owns the wording so one app does not say "Show" while
78
+ * the next says "Expand" for the same gesture. */
79
+ checklist: { expand: string; collapse: string };
76
80
  sectionHeading: { info: string };
77
81
  /** `ErrorState`'s retry button. The kit owns the wording so "try again" is
78
82
  * phrased identically everywhere instead of hand-written per app. */
@@ -213,6 +217,9 @@ export interface LoticsLocale {
213
217
  * copied stays a per-instance `label` ("Copy phone number") — that names a
214
218
  * value only the call site knows, the same split `referenceField.open` draws. */
215
219
  copyButton: { copy: string; copied: string };
220
+ /** `CommentsButton`'s accessible name — the kit owns the noun so one surface
221
+ * does not say "comments" while the next says "notes" for the same thread. */
222
+ commentsButton: { comment: string; comments: string };
216
223
  }
217
224
 
218
225
  /** The platform default — English. Every component's hardcoded default lives
@@ -245,6 +252,7 @@ export const en: LoticsLocale = {
245
252
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
246
253
  ledger: { rowDetails: (label) => `${label} details` },
247
254
  stepper: { complete: "Complete step", progress: "Progress" },
255
+ checklist: { expand: "Show", collapse: "Hide" },
248
256
  sectionHeading: { info: "About this data" },
249
257
  errorState: { retry: "Try again" },
250
258
  chip: { remove: "Remove" },
@@ -375,6 +383,7 @@ export const en: LoticsLocale = {
375
383
  approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
376
384
  messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
377
385
  copyButton: { copy: "Copy", copied: "Copied" },
386
+ commentsButton: { comment: "comment", comments: "comments" },
378
387
  };
379
388
 
380
389
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -407,6 +416,7 @@ export const vi: LoticsLocale = {
407
416
  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" },
408
417
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
409
418
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
419
+ checklist: { expand: "Mở", collapse: "Thu gọn" },
410
420
  sectionHeading: { info: "Giải thích dữ liệu" },
411
421
  errorState: { retry: "Thử lại" },
412
422
  chip: { remove: "Xóa" },
@@ -534,6 +544,7 @@ export const vi: LoticsLocale = {
534
544
  approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
535
545
  messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
536
546
  copyButton: { copy: "Sao chép", copied: "Đã sao chép" },
547
+ commentsButton: { comment: "bình luận", comments: "bình luận" },
537
548
  };
538
549
 
539
550
  const LoticsLocaleContext = createContext<LoticsLocale>(en);