@lotics/ui 22.2.0 → 23.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 (68) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +142 -2
  3. package/docs/ai_patterns.md +27 -25
  4. package/docs/catalog.md +119 -98
  5. package/docs/composition.md +134 -42
  6. package/docs/data_entry.md +67 -31
  7. package/docs/templates.md +86 -68
  8. package/examples/tpl_allocate.tsx +5 -5
  9. package/examples/tpl_attendance.tsx +2 -2
  10. package/examples/tpl_calendar.tsx +6 -6
  11. package/examples/tpl_dashboard.tsx +9 -9
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_item_list.tsx +64 -37
  14. package/examples/tpl_lookup.tsx +5 -5
  15. package/examples/tpl_pick.tsx +6 -6
  16. package/examples/tpl_pivot.tsx +3 -3
  17. package/examples/tpl_record.tsx +919 -611
  18. package/examples/tpl_report.tsx +2 -2
  19. package/examples/tpl_rollup.tsx +5 -5
  20. package/examples/tpl_shifts.tsx +5 -5
  21. package/examples/tpl_statements.tsx +7 -7
  22. package/examples/tpl_stock.tsx +2 -2
  23. package/examples/tpl_task_board.tsx +42 -26
  24. package/examples/tpl_tower.tsx +6 -6
  25. package/package.json +3 -2
  26. package/src/agent_run.tsx +40 -25
  27. package/src/breakdown.tsx +1 -1
  28. package/src/calendar/calendar_view.tsx +1 -1
  29. package/src/change_review.tsx +9 -8
  30. package/src/chip_group.tsx +12 -2
  31. package/src/choice_list.tsx +2 -2
  32. package/src/confidence.tsx +2 -2
  33. package/src/data_grid.tsx +1 -1
  34. package/src/detail_row.tsx +50 -58
  35. package/src/file_dropzone.tsx +1 -1
  36. package/src/file_gallery_modal.tsx +3 -3
  37. package/src/file_rows.tsx +1 -1
  38. package/src/finding.tsx +4 -4
  39. package/src/form_field.tsx +1 -1
  40. package/src/format_date.ts +2 -2
  41. package/src/heatmap.tsx +1 -1
  42. package/src/inline_button.tsx +84 -0
  43. package/src/inline_date_picker.tsx +17 -10
  44. package/src/inline_edit.tsx +298 -59
  45. package/src/inline_member_select.tsx +8 -3
  46. package/src/inline_number_input.tsx +11 -4
  47. package/src/inline_select.tsx +26 -13
  48. package/src/inline_text_input.tsx +12 -4
  49. package/src/inline_time_picker.tsx +10 -4
  50. package/src/ledger.tsx +2 -2
  51. package/src/locale.tsx +7 -3
  52. package/src/matrix.tsx +1 -1
  53. package/src/number_input.tsx +18 -7
  54. package/src/pipeline.tsx +1 -1
  55. package/src/popover.tsx +1 -1
  56. package/src/press_door.tsx +1 -1
  57. package/src/pressable_highlight.tsx +1 -1
  58. package/src/progress_bar.tsx +3 -3
  59. package/src/record_summary.tsx +2 -2
  60. package/src/result_header.tsx +2 -2
  61. package/src/sequence.tsx +170 -0
  62. package/src/share_or_download.ts +2 -2
  63. package/src/step_progress.tsx +7 -5
  64. package/src/stepper.tsx +1 -1
  65. package/src/task.tsx +6 -6
  66. package/src/text_input_field.tsx +19 -1
  67. package/src/text_utils.ts +1 -1
  68. package/src/linked_record_box.tsx +0 -157
@@ -1,4 +1,4 @@
1
- import { View } from "react-native";
1
+ import { StyleSheet, View, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
4
  import { chipSurfaceStyle } from "./control_surface";
@@ -62,7 +62,13 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
62
62
  // The shared pill-surface contract (height/border/radius/white +
63
63
  // hover/press wash + the selected box-shadow ring); the chip only
64
64
  // adds its horizontal padding.
65
- style={(state) => [chipSurfaceStyle(state, { selected: active }), { paddingHorizontal: 14 }]}
65
+ // The ARROW cursor, like every other control that SETS A VALUE. The
66
+ // pointer is reserved for acts that go somewhere or do something —
67
+ // a row that opens a record, a button. A pill here is the field's
68
+ // value, so it reads like the editors beside it, not like a button.
69
+ // (`PressableHighlight` defaults to a pointer; a caller's style lands
70
+ // after its own, so this is the override, not a fight.)
71
+ style={(state) => [chipSurfaceStyle(state, { selected: active }), styles.chip]}
66
72
  >
67
73
  <Text size="sm" weight={active ? "semibold" : "medium"} color={active ? "default" : "muted"}>
68
74
  {option.label}
@@ -73,3 +79,7 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
73
79
  </View>
74
80
  );
75
81
  }
82
+
83
+ const styles = StyleSheet.create({
84
+ chip: { paddingHorizontal: 14, ...({ cursor: "auto" } as ViewStyle) },
85
+ });
@@ -74,8 +74,8 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
74
74
  );
75
75
  }
76
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
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
79
  * instead of a pressable label. Typing it IS the selection; focusing it re-selects a
80
80
  * parked draft (`onFocus`). */
81
81
  function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
@@ -14,7 +14,7 @@ export interface ConfidenceLabels {
14
14
  low: string;
15
15
  }
16
16
 
17
- /** A 0–1 score folded onto the three levels: >=0.8 high · >=0.5 medium · else low. */
17
+ /** A 0–1 score folded onto the three levels: >=0.8 high, >=0.5 medium, else low. */
18
18
  export function levelFromScore(score: number): ConfidenceLevel {
19
19
  if (score >= 0.8) return "high";
20
20
  if (score >= 0.5) return "medium";
@@ -25,7 +25,7 @@ const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
25
25
  const COLOR: Record<ConfidenceLevel, ColorName> = { high: "green", medium: "amber", low: "red" };
26
26
 
27
27
  export interface ConfidenceProps {
28
- /** Pass a level directly, or a 0–1 `score` (>=0.8 high · >=0.5 medium · else low). */
28
+ /** Pass a level directly, or a 0–1 `score` (>=0.8 high, >=0.5 medium, else low). */
29
29
  level?: ConfidenceLevel;
30
30
  score?: number;
31
31
  labels?: Partial<ConfidenceLabels>;
package/src/data_grid.tsx CHANGED
@@ -59,7 +59,7 @@ export interface DataGridProps<T> {
59
59
  * high-volume paginated register (`Table` + `Pagination` + `Drawer`). It owns the
60
60
  * sortable header, the collapsible grouped sections, and the column-aligned rows;
61
61
  * the consumer owns the DATA, the sort/group/filter/collapse STATE, the toolbar
62
- * (search · group-by · filters), the per-group add (`renderGroupFooter`), and every
62
+ * (search, group-by, filters), the per-group add (`renderGroupFooter`), and every
63
63
  * CELL (`columns[].cell` renders ANY field). It renders ALL rows — no pagination /
64
64
  * virtualization — so it's for hundreds, low-thousands; at 10k+ use the register.
65
65
  * Worked example: `tpl_task_board`.
@@ -1,6 +1,7 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { MIN_CONTROL_WIDTH } from "./control_surface";
4
+ import { SPACE } from "./spacing";
4
5
  import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
5
6
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
7
  import { getInputLineHeight } from "./text_utils";
@@ -8,7 +9,6 @@ import { Text } from "./text";
8
9
 
9
10
  interface DetailTableColumns {
10
11
  labelWidth: number;
11
- trailingWidth?: number;
12
12
  minHeight: number;
13
13
  /** Container too narrow for the side-by-side columns — rows render STACKED
14
14
  * (label above a full-width value row). */
@@ -28,11 +28,6 @@ export const DETAIL_LABEL_WIDTH = 130;
28
28
  export interface DetailTableProps {
29
29
  /** Label column width (px). Default 130. */
30
30
  labelWidth?: number;
31
- /** Reserved width (px) for the trailing column. Set it when ANY row carries a
32
- * trailing action/unit/badge: EVERY row then reserves the column, so all
33
- * value cells share ONE width and the trailing items align at one x — the
34
- * table look. Omit when no row has a trailing (values fill to the edge). */
35
- trailingWidth?: number;
36
31
  /** Min row height. Default 40 (`INLINE_CONTROL_HEIGHT`) so editor, static,
37
32
  * and custom rows all sit on the inline-control baseline grid. */
38
33
  minHeight?: number;
@@ -47,35 +42,37 @@ export interface DetailTableProps {
47
42
  }
48
43
 
49
44
  /**
50
- * The column grid for a stack of `DetailRow`s — label · value · trailing laid
51
- * out like a TABLE: one label width, one value width, one trailing width, set
52
- * ONCE on the parent instead of repeated (and drifting) per row. Rows without a
53
- * trailing still reserve the trailing column, so a `Copy` action on one row
54
- * never makes its editor narrower than its neighbours'.
45
+ * The column grid for a stack of `DetailRow`s — label, value laid out like a
46
+ * TABLE: one label width, one value width, set ONCE on the parent instead of
47
+ * repeated (and drifting) per row. The value column FILLS whatever the label
48
+ * leaves; there is no third column, and nothing a row does can make its
49
+ * neighbours' editors narrower.
55
50
  *
56
- * <DetailTable labelWidth={130} trailingWidth={96}>
57
- * <DetailRow label="Reference" trailing={<Button title="Copy" … />}>
58
- * <InlineTextInput … />
51
+ * <DetailTable labelWidth={130}>
52
+ * <DetailRow label="Reference">
53
+ * <InlineTextInput … actions={<InlineButton title="Copy" … />} />
59
54
  * </DetailRow>
60
55
  * <DetailRow label="Priority"><InlineSelect … /></DetailRow>
61
56
  * </DetailTable>
62
57
  *
63
- * It owns the 6px row gap (zinc-50 editor chips need visible separation) and
64
- * the 40px row height of the inline-control grid.
58
+ * A verb about a VALUE rides the FIELD (`InlineButton` in an editor's
59
+ * `actions`), never a column beside it: the act names the value, so it travels
60
+ * with what it acts on, and a reserved column is paid by every row for the
61
+ * benefit of one.
62
+ *
63
+ * It owns the row gap and the 40px row height of the inline-control grid.
65
64
  *
66
65
  * RESPONSIVE: the table measures its own container (not the viewport — a table
67
66
  * inside a narrow Drawer on a wide screen must adapt too). When the columns
68
67
  * would crush the value cell below a usable width, every row STACKS: label
69
- * above a full-width value row (trailing stays beside the value). No prop —
70
- * the switch is automatic.
68
+ * above a full-width value. No prop the switch is automatic.
71
69
  */
72
70
  export function DetailTable(props: DetailTableProps) {
73
- const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
71
+ const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
74
72
  const [width, setWidth] = useState<number | null>(null);
75
- const stacked =
76
- width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
73
+ const stacked = width != null && width < labelWidth + minValueWidth + 24;
77
74
  return (
78
- <DetailTableContext.Provider value={{ labelWidth, trailingWidth, minHeight, stacked }}>
75
+ <DetailTableContext.Provider value={{ labelWidth, minHeight, stacked }}>
79
76
  <View
80
77
  onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
81
78
  // Measure-then-REVEAL: the unmeasured first frame renders invisible
@@ -94,13 +91,6 @@ export interface DetailRowProps extends FieldAnnotationProps {
94
91
  label: ReactNode;
95
92
  /** The value — any node (a `Text`, a `Badge`, a stack). */
96
93
  children: ReactNode;
97
- /** An optional right-side slot, rendered as the LAST child of the row (after
98
- * `children`) — a small action `Button`, a status `Badge`, an `IconButton`.
99
- * NOT for units: "kg" / "₫" belong IN the value (`InlineNumberInput format`).
100
- * In form mode (`labelWidth` set) the value column already fills
101
- * the row, so the trailing pins to the right edge automatically. Inside a
102
- * `DetailTable` with `trailingWidth` it sits in the reserved trailing column. */
103
- trailing?: ReactNode;
104
94
  /** Fixed label-column width (px) to align labels in a left column — for a peek
105
95
  * or form-like stack. Omit for a spread row: the label takes the slack and the
106
96
  * value sits at the right edge (the default drawer-detail look). Inside a
@@ -125,17 +115,21 @@ export interface DetailRowProps extends FieldAnnotationProps {
125
115
  * to the right edge); pass `labelWidth` to align labels in a fixed left column —
126
116
  * the value column then FILLS the rest of the row, so a stack of inline editors
127
117
  * all span the same width (none jumps wider on edit). For a stack of rows, wrap
128
- * them in a `DetailTable`: the label/trailing column widths live ONCE on the
129
- * parent and every row aligns.
118
+ * them in a `DetailTable`: the label column width lives ONCE on the parent and
119
+ * every row aligns.
120
+ *
121
+ * There is no trailing slot. A verb about the VALUE goes on the field
122
+ * (`InlineButton` in an editor's `actions`); anything else — a status `Badge`, a
123
+ * unit — belongs IN the value, composed beside the control.
130
124
  */
131
125
  export function DetailRow(props: DetailRowProps) {
132
126
  const table = useContext(DetailTableContext);
133
- const { label, children, trailing, labelSize = "sm", description, warning, error, flat } = props;
127
+ const { label, children, labelSize = "sm", description, warning, error, flat } = props;
134
128
  const labelWidth = props.labelWidth ?? table?.labelWidth;
135
129
  const minHeight = props.minHeight ?? table?.minHeight ?? 28;
136
130
  const form = labelWidth != null;
137
131
  // Annotations (description / warning / error) live UNDER THE VALUE — the row
138
- // grows, so the label + trailing center against the CONTROL LINE, not the block.
132
+ // grows, so the label centers against the CONTROL LINE, not the block.
139
133
  const annotated = hasFieldAnnotation(props);
140
134
  // A FLAT value (no painted chip) centers its text in the control band,
141
135
  // leaving invisible slack below it — tuck the annotations up by that slack
@@ -169,12 +163,11 @@ export function DetailRow(props: DetailRowProps) {
169
163
  {/* Wraps, like the horizontal label — a stacked row has the FULL width
170
164
  to spend, so clipping here would be gratuitous. */}
171
165
  <Text weight="medium">{label}</Text>
172
- {/* stacked wears the FORM grammar exactly: label · description ·
173
- control · warning · error (the `FormField` order) */}
166
+ {/* stacked wears the FORM grammar exactly: label, description,
167
+ control, warning, error (the `FormField` order) */}
174
168
  {description != null ? <Text color="muted">{description}</Text> : null}
175
169
  <View style={[styles.stackedValueRow, { minHeight }]}>
176
170
  <View style={styles.value}>{children}</View>
177
- {trailing != null ? trailing : null}
178
171
  </View>
179
172
  {warning != null ? (
180
173
  <Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
@@ -189,10 +182,10 @@ export function DetailRow(props: DetailRowProps) {
189
182
  </View>
190
183
  );
191
184
  }
192
- // ONE alignment law for every row: the row TOP-ALIGNS and each cell (label ·
193
- // control · trailing) centers within the first CONTROL LINE (`minHeight`) —
194
- // a tall value block (a multi-control stack, annotations) never drags the
195
- // label or trailing toward the block's center.
185
+ // ONE alignment law for every row: the row TOP-ALIGNS and each cell (label,
186
+ // control) centers within the first CONTROL LINE (`minHeight`) — a tall value
187
+ // block (a multi-control stack, annotations) never drags the label toward the
188
+ // block's center.
196
189
  return (
197
190
  <View style={[styles.row, { minHeight }]}>
198
191
  {labelCell(form ? { width: labelWidth } : styles.flexLabel)}
@@ -207,20 +200,24 @@ export function DetailRow(props: DetailRowProps) {
207
200
  {flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
208
201
  </View>
209
202
  )}
210
- {table?.trailingWidth != null ? (
211
- <View style={[styles.trailingCell, { width: table.trailingWidth, minHeight }]}>{trailing}</View>
212
- ) : trailing != null ? (
213
- <View style={[styles.trailingCell, { minHeight }]}>{trailing}</View>
214
- ) : null}
215
203
  </View>
216
204
  );
217
205
  }
218
206
 
219
207
  const styles = StyleSheet.create({
220
- // 8px between rows the zinc-50 editor chips need visible separation.
221
- table: { gap: 8 },
222
- // Stacked (narrow) mode: label-over-value blocks need more air between rows.
223
- tableStacked: { gap: 14 },
208
+ // `SPACE.md` between rows. It was 8 while a resting field was a tint: two soft
209
+ // chips that close together still read as separate. A field now carries a
210
+ // BORDER, so at 8 the page shows a stack of hard edges 8px apart and the rows
211
+ // fuse into one block — the same gap, twice as loud. 16 is the next rung of the
212
+ // 8-grid (12 would read as developer-tuned), and it also settles a second
213
+ // question: an annotated row's `description` sits 2px under ITS value, so the
214
+ // gap to the NEXT row has to be unmistakably larger or the annotation looks
215
+ // like it belongs to whatever follows.
216
+ table: { gap: SPACE.md },
217
+ // Stacked (narrow) mode: a row is a label-over-value BLOCK, so the gap between
218
+ // rows must beat the gap inside one — `SPACE.lg`, the next rung up, on the same
219
+ // grid (it was 14, off-scale).
220
+ tableStacked: { gap: SPACE.lg },
224
221
  unmeasured: { opacity: 0 },
225
222
  stackedRow: { gap: 4 },
226
223
  stackedValueRow: {
@@ -242,7 +239,7 @@ const styles = StyleSheet.create({
242
239
  // `flex-start` — that drops every single-line label to the band's top edge.
243
240
  labelCell: { justifyContent: "center" },
244
241
  // The first line of the value cell — the control centers in it exactly like
245
- // the label and trailing cells do.
242
+ // the label cell does.
246
243
  controlLine: { justifyContent: "center" },
247
244
  // The 2px between the control line and the annotation block beneath it — the
248
245
  // same rhythm `FieldAnnotations` puts between its own lines.
@@ -250,13 +247,8 @@ const styles = StyleSheet.create({
250
247
  // Form mode (labelWidth set): the value column FILLS the row so every inline
251
248
  // editor spans the SAME width — and none jumps wider when it swaps to the
252
249
  // (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
253
- // child fills it edge-to-edge. A status Badge or action is NOT a value pass
254
- // it as `trailing` (pins right); one placed here would stretch full-width.
250
+ // child fills it edge-to-edge. Something that is NOT the value but belongs
251
+ // beside it a status Badge composes into a row INSIDE this cell, so it
252
+ // costs only the row that wants it.
255
253
  value: { flex: 1 },
256
- // The reserved trailing column: every row renders it (empty or not) so value
257
- // cells share one width and trailing items align at one x, like a table.
258
- trailingCell: {
259
- flexDirection: "row",
260
- alignItems: "center",
261
- },
262
254
  });
@@ -22,7 +22,7 @@ export interface FileDropzoneProps {
22
22
  label?: string;
23
23
  /** Secondary line. Defaults to the locale's `fileDropzone.hint` ("or click to
24
24
  * browse" / "hoặc bấm để chọn"); override with an accepted-types line
25
- * (" · PDF, ảnh"). */
25
+ * ("…, PDF, ảnh"). */
26
26
  hint?: string;
27
27
  /** Main line while a drag hovers the zone ("Thả để tải lên"). */
28
28
  dropLabel?: string;
@@ -1,5 +1,5 @@
1
- // FileGalleryModal — full-screen file preview with a toolbar (filename · counter
2
- // · actions · close), prev/next nav, and ESC close. Pure primitive: takes
1
+ // FileGalleryModal — full-screen file preview with a toolbar (filename, counter,
2
+ // actions, close), prev/next nav, and ESC close. Pure primitive: takes
3
3
  // DisplayFile[] only, no Lotics domain coupling. Renders every previewable type
4
4
  // (image/PDF/video/audio/Word/Excel/CSV) via FilePreview, whose document engines
5
5
  // (pdfjs-dist, @lotics/docx, @lotics/xlsx) are optional peer deps loaded lazily —
@@ -232,7 +232,7 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
232
232
  // Phone: actions collapse to a ⋯ menu.
233
233
  <ActionMenu items={actionItems} accessibilityLabel={l.actions} />
234
234
  ) : (
235
- // Wider screens: every action inline (download · open · remove).
235
+ // Wider screens: every action inline (download, open, remove).
236
236
  // Rotate lives on the image overlay, not the toolbar.
237
237
  <>
238
238
  <Button
package/src/file_rows.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  // FileRows — the common "show a list of files" surface, batteries included: each
2
2
  // file is a FileRow whose whole-row press opens the built-in full-screen
3
- // FileGalleryModal, with a ⋯ menu (Download · Open in new tab · Remove) on the
3
+ // FileGalleryModal, with a ⋯ menu (Download, Open in new tab, Remove) on the
4
4
  // trailing edge. Pure kit composition (FileRow + ActionMenu + FileGalleryModal) —
5
5
  // reach for the pieces directly only when you need a different shape.
6
6
 
package/src/finding.tsx CHANGED
@@ -44,8 +44,8 @@ export interface FindingProps {
44
44
 
45
45
  /**
46
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
47
+ * observation, a briefing item. Good defaults (severity word, title, detail,
48
+ * the PROMINENT metric, `Sources` chips) with a `children` slot for any
49
49
  * extra composition. Display-only: a finding informs the verdict the host
50
50
  * records; it decides nothing itself. Stack several most-severe first —
51
51
  * inside a `ChangeReview` wrap each in a display-only `Change` (the family's
@@ -86,7 +86,7 @@ export function Finding(props: FindingProps) {
86
86
 
87
87
  export interface FindingComparisonProps {
88
88
  /** The disagreeing sides — each value with where it came from
89
- * ("invoice.pdf" · "480 pcs"). Two or more. */
89
+ * ("invoice.pdf", "480 pcs"). Two or more. */
90
90
  values: { label: string; value: string }[];
91
91
  /** The computed difference, EMPHASIZED ("−40 pcs"). */
92
92
  delta?: string;
@@ -96,7 +96,7 @@ export interface FindingComparisonProps {
96
96
 
97
97
  /**
98
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
99
+ * (source, value), a hairline, then the DELTA emphasized. Compose it as the
100
100
  * finding's children; it replaces ad-hoc metric text for any
101
101
  * one-value-disagrees insight (quantities, totals, dates).
102
102
  */
@@ -101,7 +101,7 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
101
101
  <Spacer size={8} />
102
102
  {/*
103
103
  Below the control, above the error — the three tones read
104
- description · warning · error, ascending severity. `role="alert"`
104
+ description, warning, error, ascending severity. `role="alert"`
105
105
  + `aria-live="polite"` announce it politely on appearance.
106
106
  */}
107
107
  <Text nativeID={warningId} color="warning" accessibilityRole="alert" aria-live="polite">
@@ -103,8 +103,8 @@ export function toISODate(value: Date | string | null | undefined): string {
103
103
  /**
104
104
  * THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
105
105
  * and returns a localized display string; defaults to the home market. `format` picks the date
106
- * style: `date` → `22/05/2026` · `medium` → `22 thg 5, 2026` · `long` → `22 tháng 5, 2026` ·
107
- * `dayMonth` → `22 thg 5` · `monthYear` → `Tháng 5 2026`. **`time: true` prepends the 24h time to
106
+ * style: `date` → `22/05/2026`; `medium` → `22 thg 5, 2026`; `long` → `22 tháng 5, 2026`;
107
+ * `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 2026`. **`time: true` prepends the 24h time to
108
108
  * ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
109
109
  * year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
110
110
  * `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
package/src/heatmap.tsx CHANGED
@@ -57,7 +57,7 @@ export function Heatmap(props: HeatmapProps) {
57
57
  return (
58
58
  <Cell
59
59
  key={col.key}
60
- label={`${row.label} · ${col.label}: ${formatValue(value)}`}
60
+ label={`${row.label}, ${col.label}: ${formatValue(value)}`}
61
61
  background={value === 0 ? colors.zinc[100] : withAlpha(color, 0.15 + 0.85 * (value / max))}
62
62
  selected={isSelected}
63
63
  onPress={
@@ -0,0 +1,84 @@
1
+ import { type GestureResponderEvent } from "react-native";
2
+ import { Text } from "./text";
3
+ import { colors } from "./colors";
4
+ import { PressableHighlight } from "./pressable_highlight";
5
+ import { CONTROL_TRANSITION } from "./control_surface";
6
+
7
+ /** The height that fits INSIDE the 40px inline control band with 6px of air
8
+ * above and below — the same air a `DetailRow`'s label gets against its value. */
9
+ const INLINE_BUTTON_HEIGHT = 28;
10
+
11
+ interface InlineButtonProps {
12
+ /** The verb, as a word. An inline button is never icon-only — it sits on a
13
+ * crowded value where a bare glyph reads as decoration on the field. */
14
+ title: string;
15
+ onPress: () => void;
16
+ /** Announced name, when the surrounding value makes `title` ambiguous
17
+ * ("Open" → "Open customer"). Defaults to `title`. */
18
+ accessibilityLabel?: string;
19
+ tooltip?: string;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * A verb that lives INSIDE an inline field's surface — the Open on a reference,
25
+ * the Copy on a value worth copying — rather than in the row's trailing column.
26
+ *
27
+ * Inside is where it belongs when the act is ABOUT THE VALUE: it travels with
28
+ * what it acts on, so a reader never has to pair a control on the right edge of
29
+ * the page with the field it belongs to, and the trailing column stays free for
30
+ * row-level verbs.
31
+ *
32
+ * It is FILLED, on a field surface that is white. The contrast is the whole
33
+ * affordance, and it is a RELATIONSHIP rather than a colour: the verb must read
34
+ * as sitting ON the value, so it takes the tint the field does not. (Invert it
35
+ * again if the resting field surface ever changes, or the verb dissolves into
36
+ * the value it acts on.) It borrows `Button secondary`'s own fill, so the two
37
+ * read as one act at two scales; the kit's `Button` still cannot serve here,
38
+ * being the full 40px control height that this sits inside.
39
+ *
40
+ * NO BORDER and NO SHADOW: the fill alone separates it — a border would draw a
41
+ * box inside a box, now that the field has one — and depth belongs to the
42
+ * primary action alone (a drop shadow on a 28px control reads as a smudge, not
43
+ * lift).
44
+ *
45
+ * **It stops propagation.** That is the contract, not the styling: an inline
46
+ * button sits on a surface that is itself pressable (the field opens its editor,
47
+ * a reference opens its peek), so without this every press would fire both — the
48
+ * bug is silent, and it is the reason this is a primitive rather than a local
49
+ * composition.
50
+ */
51
+ export function InlineButton(props: InlineButtonProps) {
52
+ const { title, onPress, accessibilityLabel, tooltip, disabled } = props;
53
+ return (
54
+ <PressableHighlight
55
+ focusRing
56
+ accessibilityRole="button"
57
+ accessibilityLabel={accessibilityLabel ?? title}
58
+ tooltip={tooltip}
59
+ disabled={disabled}
60
+ onPress={(e: GestureResponderEvent) => {
61
+ e?.stopPropagation?.();
62
+ onPress();
63
+ }}
64
+ style={(state) => [
65
+ {
66
+ height: INLINE_BUTTON_HEIGHT,
67
+ paddingHorizontal: 10,
68
+ justifyContent: "center",
69
+ // Softer than the 10 of the field it sits in: a control nested inside
70
+ // another reads as a distinct object when its corners are rounder than
71
+ // its host's, and at 28px tall a 10 would look square by comparison.
72
+ borderRadius: 8,
73
+ backgroundColor: state.hovered && !disabled ? colors.zinc[200] : colors.zinc[100],
74
+ opacity: disabled ? 0.5 : 1,
75
+ },
76
+ CONTROL_TRANSITION,
77
+ ]}
78
+ >
79
+ <Text size="xs" weight="medium" color={disabled ? "muted" : "default"}>
80
+ {title}
81
+ </Text>
82
+ </PressableHighlight>
83
+ );
84
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
@@ -35,9 +35,14 @@ export interface InlineDatePickerProps {
35
35
  * order. Defaults to the active `LoticsLocaleProvider` locale. */
36
36
  locale?: string;
37
37
  disabled?: boolean;
38
- accessibilityLabel?: string;
39
- /** Form field (default) or grid cell — see `InlineEditVariant`. */
38
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
40
39
  variant?: InlineEditVariant;
40
+ /** Verbs on the field's surface — an `InlineButton` Copy, Open… See
41
+ * `InlineEditView.actions`: they sit INSIDE the field, in the same place in
42
+ * every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
43
+ * has nothing to act on) or the field resizes as the value fills. */
44
+ actions?: ReactNode;
45
+ accessibilityLabel?: string;
41
46
  /** Colour the resting date by urgency — overdue red, soon-due amber (the caller owns
42
47
  * the rule; e.g. a task's `dueTone`). Applies to a set value only. */
43
48
  tone?: TextColor;
@@ -59,7 +64,7 @@ export interface InlineDatePickerProps {
59
64
  * The row never changes height in either mode.
60
65
  */
61
66
  export function InlineDatePicker(props: InlineDatePickerProps) {
62
- const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , variant, tone } = props;
67
+ const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , tone, variant, actions } = props;
63
68
  const locales = useLoticsLocale();
64
69
  const inlineLabels = locales.inline;
65
70
  const dateLabels = locales.datePicker;
@@ -236,11 +241,9 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
236
241
  ) : (
237
242
  <Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />
238
243
  );
239
- // The resting calendar glyph is a FORM affordance (marks an empty field as a date
240
- // control). A `cell` lives in a grid where the column header already says "Due" and
241
- // every cell edits — the repeated glyph is noise, so it reads value-first (the saving
242
- // spinner still shows). The typed-edit field keeps its glyph — that's the open-picker button.
243
- const restTrailing = variant === "cell" && !saving ? undefined : trailing;
244
+ // The resting calendar glyph marks an empty field as a DATE control — the same
245
+ // promise on every surface, now that a field looks the same everywhere.
246
+ const restTrailing = trailing;
244
247
 
245
248
  return (
246
249
  <View>
@@ -268,7 +271,11 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
268
271
  <PopoverTrigger>
269
272
  <InlineEditView
270
273
  variant={variant}
271
- tone={tone}
274
+ actions={actions}
275
+ // The calendar anchors to the FIELD, not to the value region: with
276
+ // verbs on the surface the region inside is narrower than the box.
277
+ anchorRef={anchorRef}
278
+ tone={tone}
272
279
  display={display}
273
280
  placeholder={placeholder}
274
281
  disabled={disabled}