@lotics/ui 22.3.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 +22 -22
  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 +4 -4
  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 { useCallback } from "react";
1
+ import { useCallback, type ReactNode } from "react";
2
2
  import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
3
3
  import { TextInputField } from "./text_input_field";
4
4
  import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
@@ -15,9 +15,14 @@ export interface InlineTextInputProps {
15
15
  disabled?: boolean;
16
16
  /** Strike + mute the resting value (a completed item that stays editable). */
17
17
  struck?: boolean;
18
- /** Resting surface "tint" (default, the zinc-50 chip) or "transparent". */
18
+ /** How much frame shows at rest see {@link InlineEditVariant}. Default "framed". */
19
19
  variant?: InlineEditVariant;
20
20
  accessibilityLabel?: string;
21
+ /** Verbs on the field's surface — an `InlineButton` Copy on a reference a reader
22
+ * quotes elsewhere. See `InlineEditView.actions`: rendered in BOTH modes so the
23
+ * field never changes width on focus, and passed UNCONDITIONALLY (`disabled` the
24
+ * verb when there is nothing to act on). */
25
+ actions?: ReactNode;
21
26
  }
22
27
 
23
28
  /**
@@ -27,7 +32,7 @@ export interface InlineTextInputProps {
27
32
  * value in a dense record / detail surface.
28
33
  */
29
34
  export function InlineTextInput(props: InlineTextInputProps) {
30
- const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant } = props;
35
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions } = props;
31
36
  const edit = useInlineEdit<string>({ value, onSave });
32
37
 
33
38
  const onKeyPress = useCallback(
@@ -49,7 +54,6 @@ export function InlineTextInput(props: InlineTextInputProps) {
49
54
 
50
55
  return (
51
56
  <InlineEditFrame
52
- variant={variant}
53
57
  editing={edit.editing}
54
58
  display={value}
55
59
  placeholder={placeholder}
@@ -61,7 +65,9 @@ export function InlineTextInput(props: InlineTextInputProps) {
61
65
  error={edit.error}
62
66
  disabled={disabled}
63
67
  struck={struck}
68
+ variant={variant}
64
69
  accessibilityLabel={accessibilityLabel}
70
+ actions={actions}
65
71
  >
66
72
  <TextInputField
67
73
  value={edit.draft}
@@ -71,6 +77,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
71
77
  autoFocus
72
78
  placeholder={placeholder}
73
79
  accessibilityLabel={accessibilityLabel}
80
+ // With verbs on the field, the FRAME owns the surface and the ring.
81
+ seamless={actions != null}
74
82
  />
75
83
  </InlineEditFrame>
76
84
  );
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback, useRef, type ReactNode } from "react";
2
2
  import { Icon } from "./icon";
3
3
  import { colors } from "./colors";
4
4
  import { TimePicker } from "./time_picker";
@@ -14,9 +14,14 @@ export interface InlineTimePickerProps {
14
14
  * an explicit ✓ saves and ✕ reverts. */
15
15
  controls?: InlineEditControls;
16
16
  disabled?: boolean;
17
- accessibilityLabel?: string;
18
- /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
17
+ /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
19
18
  variant?: InlineEditVariant;
19
+ /** Verbs on the field's surface — an `InlineButton` Copy, Open… See
20
+ * `InlineEditView.actions`: they sit INSIDE the field, in the same place in
21
+ * every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
22
+ * has nothing to act on) or the field resizes as the value fills. */
23
+ actions?: ReactNode;
24
+ accessibilityLabel?: string;
20
25
  }
21
26
 
22
27
  /**
@@ -25,7 +30,7 @@ export interface InlineTimePickerProps {
25
30
  * field at the same height, so the form never reflows.
26
31
  */
27
32
  export function InlineTimePicker(props: InlineTimePickerProps) {
28
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
33
+ const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
29
34
  const edit = useInlineEdit<string>({ value, onSave });
30
35
  const dateLabels = useLoticsLocale().datePicker;
31
36
  // Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
@@ -42,6 +47,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
42
47
  return (
43
48
  <InlineEditFrame
44
49
  variant={variant}
50
+ actions={actions}
45
51
  editing={edit.editing}
46
52
  display={value}
47
53
  placeholder={placeholder}
package/src/ledger.tsx CHANGED
@@ -23,7 +23,7 @@ import { Text } from "./text";
23
23
  // <LedgerRow label="Levy" value={levy} reference={{ label: "INV-0414", onPress }} />
24
24
  // </LedgerGroup>
25
25
  // <LedgerGroup label="Received" total={-received}>
26
- // <LedgerRow label="Deposit" meta="03/06 · Cash" value={-1_550_000} tone="success" />
26
+ // <LedgerRow label="Deposit" meta="03/06, Cash" value={-1_550_000} tone="success" />
27
27
  // </LedgerGroup>
28
28
  // <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
29
29
  // </Ledger>
@@ -95,7 +95,7 @@ export function LedgerGroup(props: LedgerGroupProps) {
95
95
  export interface LedgerRowProps {
96
96
  /** The line's name — what was charged / received. */
97
97
  label: string;
98
- /** Inline context after the label — a date · method, a basis, a period. */
98
+ /** Inline context after the label — a date, method, a basis, a period. */
99
99
  meta?: string;
100
100
  /** The amount. Negative renders "− <abs>" (a receipt). */
101
101
  value: number;
package/src/locale.tsx CHANGED
@@ -65,6 +65,8 @@ export interface LoticsLocale {
65
65
  sectionHeading: { info: string };
66
66
  /** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
67
67
  chip: { remove: string };
68
+ /** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
69
+ sequence: { moveUp: string; moveDown: string; remove: string };
68
70
  /** `TrendFooter`: the direction words of the trend sentence. */
69
71
  trendFooter: { up: string; down: string };
70
72
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
@@ -114,12 +116,12 @@ export interface LoticsLocale {
114
116
  overlay: { close: string };
115
117
  /** `FileDropzone`: the resting main line, the secondary hint, and the
116
118
  * drag-hover line. `hint` is a generic default — apps override the prop with
117
- * an accepted-types line (" · PDF, ảnh"). */
119
+ * an accepted-types line ("…, PDF, ảnh"). */
118
120
  fileDropzone: { label: string; hint: string; drop: string };
119
121
  /** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
120
122
  fileThumbnail: { remove: string };
121
- /** `FilesEditor`'s shipped bar pieces (upload · select · select-all · download
122
- * · remove) and the remove confirm. A HOST verb is a plain `Button` and names
123
+ /** `FilesEditor`'s shipped bar pieces (upload, select, select-all, download,
124
+ * remove) and the remove confirm. A HOST verb is a plain `Button` and names
123
125
  * itself, so nothing here is about what an app happens to do with a file. */
124
126
  filesEditor: {
125
127
  upload: string;
@@ -222,6 +224,7 @@ export const en: LoticsLocale = {
222
224
  ledger: { rowDetails: (label) => `${label} details` },
223
225
  sectionHeading: { info: "About this data" },
224
226
  chip: { remove: "Remove" },
227
+ sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
225
228
  trendFooter: { up: "Up", down: "Down" },
226
229
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
227
230
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
@@ -373,6 +376,7 @@ export const vi: LoticsLocale = {
373
376
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
374
377
  sectionHeading: { info: "Giải thích dữ liệu" },
375
378
  chip: { remove: "Xóa" },
379
+ sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
376
380
  trendFooter: { up: "Tăng", down: "Giảm" },
377
381
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
378
382
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
package/src/matrix.tsx CHANGED
@@ -203,7 +203,7 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
203
203
 
204
204
  const isSelected = selected?.row === row.key && selected?.col === col.key;
205
205
  const pressable = !!onSelectCell && value > 0;
206
- const label = `${row.label} · ${col.label}: ${formatValue(value)}`;
206
+ const label = `${row.label}, ${col.label}: ${formatValue(value)}`;
207
207
 
208
208
  const content = showNumber ? (
209
209
  <Text size="sm" weight="medium" tabular align="center" color={value === 0 ? "zinc-500" : "default"}>
@@ -18,6 +18,17 @@ export interface NumberInputProps {
18
18
  disabled?: boolean;
19
19
  testID?: string;
20
20
  accessibilityLabel?: string;
21
+ /** The HOST paints the surface — drop this input's own border, fill, focus ring
22
+ * AND horizontal padding, so the page draws one box and the text lands on the
23
+ * host's inset rather than 8px further in. Set by an inline editor whose field
24
+ * carries `actions`: the field's shell owns the surface (so the verbs never
25
+ * move between modes) and owns the ring with it.
26
+ *
27
+ * HEIGHT and VERTICAL padding stay — those are what keep the field the same
28
+ * size in both modes; the horizontal inset is what has to be surrendered,
29
+ * because the host already spent `CONTROL_TEXT_INSET` on it and two insets
30
+ * stacked is a visible jump to the right on focus. */
31
+ seamless?: boolean;
21
32
  }
22
33
 
23
34
  /**
@@ -26,7 +37,7 @@ export interface NumberInputProps {
26
37
  * number in place on a record use `InlineNumberInput`.
27
38
  */
28
39
  export function NumberInput(props: NumberInputProps) {
29
- const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
40
+ const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
30
41
  const binding = useFormField();
31
42
  const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
32
43
  const { focusVisible, focusProps } = useFocusRing({ always: true });
@@ -55,17 +66,17 @@ export function NumberInput(props: NumberInputProps) {
55
66
  autoFocus={autoFocus}
56
67
  style={{
57
68
  height: 40,
58
- paddingLeft: 8,
59
- paddingRight: 8,
69
+ paddingLeft: seamless ? 0 : 8,
70
+ paddingRight: seamless ? 0 : 8,
60
71
  borderRadius: CONTROL_RADIUS,
61
- borderWidth: 1,
72
+ borderWidth: seamless ? 0 : 1,
62
73
  borderStyle: "solid",
63
- borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
64
- backgroundColor: colors.background,
74
+ borderColor: seamless ? "transparent" : hovered && !disabled ? HOVER_BORDER : colors.border,
75
+ backgroundColor: seamless ? "transparent" : colors.background,
65
76
  fontFamily: fontFamilyRegular,
66
77
  ...inputTextStyleWeb,
67
78
  letterSpacing: -0.4,
68
- boxShadow: focusVisible ? FOCUS_RING : "none",
79
+ boxShadow: !seamless && focusVisible ? FOCUS_RING : "none",
69
80
  outline: "none",
70
81
  boxSizing: "border-box",
71
82
  transition: "border-color 0.12s, box-shadow 0.12s",
package/src/pipeline.tsx CHANGED
@@ -45,7 +45,7 @@ export interface PipelineProps {
45
45
  * <PipelineStage status="done" title="Submitted">
46
46
  * <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
47
47
  * </PipelineStage>
48
- * <PipelineStage status="current" title="In review" meta="Waiting 3d · Ops">
48
+ * <PipelineStage status="current" title="In review" meta="Waiting 3d, Ops">
49
49
  * <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
50
50
  * <PipelineActions>
51
51
  * <Button title="Approve" color="primary" />
package/src/popover.tsx CHANGED
@@ -656,7 +656,7 @@ export function PopoverContent(props: PopoverContentProps) {
656
656
  // the field.
657
657
  //
658
658
  // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
659
- // `variant="cell"` select in a dense grid column — still opens a list
659
+ // a select in a dense grid column — still opens a list
660
660
  // wide enough to read, rather than inheriting a width nothing fits in.
661
661
  ...(inheritTriggerWidth &&
662
662
  !small &&
@@ -42,7 +42,7 @@ export interface PressDoorProps {
42
42
  * Wrap the content in the pressable itself (`PressableHighlight`, `FileRow`'s door) ONLY
43
43
  * when that content is non-interactive by construction.
44
44
  *
45
- * `TableRow` and `LinkedRecordBox` are the in-kit consumers.
45
+ * `TableRow` is the in-kit consumer.
46
46
  */
47
47
  export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
48
48
  const { focusVisible, focusProps } = useFocusRing();
@@ -76,7 +76,7 @@ export interface PressableHighlightProps extends PressableProps {
76
76
  * checkbox, a `Link`) must not be built on it: a button cannot contain interactive
77
77
  * descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead —
78
78
  * the surface takes the mouse, the door takes the keyboard, the controls stay their own
79
- * tab stops (`TableRow` and `LinkedRecordBox` are the references).
79
+ * tab stops (`TableRow` is the reference).
80
80
  */
81
81
  export function PressableHighlight(props: PressableHighlightProps) {
82
82
  const {
@@ -13,7 +13,7 @@ export interface ProgressBarProps {
13
13
  * it. */
14
14
  title?: string;
15
15
  /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
16
- * "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
16
+ * "1,250 / 2,500 (50%)" (separators follow the reader's locale). Reports the
17
17
  * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
18
18
  format?: ProgressBarFormat;
19
19
  /**
@@ -58,7 +58,7 @@ export function ProgressBar(props: ProgressBarProps) {
58
58
  const localeTag = useLocaleTag();
59
59
  /**
60
60
  * The TRACK clamps — a fill wider than its own track is meaningless. The CAPTION does not: a
61
- * meter reading `2.100 / 2.000 · 100%` asserts a number that is false, and a reader scanning
61
+ * meter reading `2.100 / 2.000 (100%)` asserts a number that is false, and a reader scanning
62
62
  * percentages sees "exactly at the limit" when they are over it. Progress-toward-completion
63
63
  * never exceeds its max, so this only diverges for the over-allowance case, where the true
64
64
  * figure is the whole point.
@@ -87,7 +87,7 @@ export function ProgressBar(props: ProgressBarProps) {
87
87
 
88
88
  const caption =
89
89
  format === "fraction"
90
- ? `${num(value)} / ${num(max)} · ${Math.round(ratio)}%`
90
+ ? `${num(value)} / ${num(max)} (${Math.round(ratio)}%)`
91
91
  : format === "percentage"
92
92
  ? `${Math.round(ratio)}%`
93
93
  : null;
@@ -20,7 +20,7 @@ export interface RecordSummaryProps {
20
20
  * the record page's one h1; pass 2 when the band sits inside a surface
21
21
  * that already owns the h1 (a drawer with its own titled header). */
22
22
  level?: 1 | 2 | 3;
23
- /** Identity qualifiers on the title line ("HCM → Hamburg · Export · FCL") —
23
+ /** Identity qualifiers on the title line ("HCM → Hamburg, Export, FCL") —
24
24
  * sm muted; wraps under the title when narrow. */
25
25
  subtitle?: string;
26
26
  /** A status chip (a `Badge`) or any small node right after the title. */
@@ -32,7 +32,7 @@ export interface RecordSummaryProps {
32
32
 
33
33
  /**
34
34
  * The identity band of a single record's detail screen or drawer: ONE row —
35
- * title · subtitle · status chip, with an optional headline metric pinned
35
+ * title, subtitle, status chip, with an optional headline metric pinned
36
36
  * right. It exists to stop hand-rolled record headers drifting: mixed type
37
37
  * scales, several competing figures, color noise.
38
38
  *
@@ -10,8 +10,8 @@ import { Text, type HeadingLevel } from "./text";
10
10
  * ALREADY CREATED. The receipt states the outcome and routes — it never edits
11
11
  * (correction happens through the record's ordinary verbs, on the record):
12
12
  *
13
- * - ONE record → `ResultHeader` (tone mark inline on the title row · outcome
14
- * title · failure reason line) + a handful of spread `DetailRow` receipt
13
+ * - ONE record → `ResultHeader` (tone mark inline on the title row, outcome
14
+ * title, failure reason line) + a handful of spread `DetailRow` receipt
15
15
  * lines (the identifying figures, not the field wall) + a `Confidence`
16
16
  * callout naming exactly which values failed their deterministic checks.
17
17
  * - SEVERAL records → an attention-first `ListItem` register whose rows press
@@ -0,0 +1,170 @@
1
+ import { Children, createContext, isValidElement, useContext, type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { IconButton } from "./icon_button";
5
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { Text } from "./text";
8
+
9
+ interface SequencePosition {
10
+ index: number;
11
+ count: number;
12
+ }
13
+
14
+ const SequenceItemContext = createContext<SequencePosition | null>(null);
15
+
16
+ /** Half the control band, less the dot's own radius — puts the dot on the SAME
17
+ * line as the first control in the item, not at the item's vertical centre (an
18
+ * item that wraps to two lines must not drag its marker down). */
19
+ const RAIL_LEAD = Math.round(INLINE_CONTROL_HEIGHT / 2) - 4;
20
+ const RAIL_WIDTH = 12;
21
+ /** The rail column plus its gap — what content must be indented by to line up
22
+ * under the items (an "Add" link, a caption). Exported so a caller never
23
+ * hand-picks a number that drifts from the rail. */
24
+ export const SEQUENCE_INSET = RAIL_WIDTH + 10;
25
+
26
+ export interface SequenceProps {
27
+ /** `SequenceItem`s, in order. Order IS the data — render them from your list. */
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * An ORDERED list whose order is part of the data — a route's stops, an
33
+ * approval chain, a set of legs — rendered as a connected rail so the sequence
34
+ * reads without a single label saying "first" or "then".
35
+ *
36
+ * Reach for it when a thing has a variable number of POSITIONS. The alternative
37
+ * — one field per position (`Origin`, `Transfer point`, `Destination`) — cannot
38
+ * hold a second middle entry and cannot say that the entries are ordered; three
39
+ * identical field rows say nothing about which comes first.
40
+ *
41
+ * It is NOT a `Timeline` (an activity FEED: who changed what, when), NOT a
42
+ * `Stepper` (fixed named positions of a wizard), and NOT a `Pipeline` (ONE
43
+ * record walking stages that each own their controls). This is a list the user
44
+ * EDITS: add, remove, reorder.
45
+ *
46
+ * Each item supplies its own content — the kit draws the rail, the position
47
+ * affordances and the spacing:
48
+ *
49
+ * <Sequence>
50
+ * {stops.map((s, i) => (
51
+ * <SequenceItem key={s.id} role={roleFor(i)} onMoveUp={…} onMoveDown={…} onRemove={…}>
52
+ * <InlineTextInput … />
53
+ * </SequenceItem>
54
+ * ))}
55
+ * </Sequence>
56
+ */
57
+ export function Sequence({ children }: SequenceProps) {
58
+ const items = Children.toArray(children).filter(isValidElement);
59
+ return (
60
+ // NO gap: the rail is continuous, so each item's trailing segment has to
61
+ // meet the next item's leading one. Item spacing is the item's own padding,
62
+ // which the rail runs through.
63
+ <View>
64
+ {items.map((child, index) => (
65
+ <SequenceItemContext.Provider key={index} value={{ index, count: items.length }}>
66
+ {child}
67
+ </SequenceItemContext.Provider>
68
+ ))}
69
+ </View>
70
+ );
71
+ }
72
+
73
+ export interface SequenceItemProps {
74
+ /** What this position IS, derived by the caller from its index (`Origin`,
75
+ * `Via`, `Destination`). Derive it, never store it — a stored role lies the
76
+ * moment the list is reordered. */
77
+ role?: string;
78
+ /** The item's editors / content. */
79
+ children: ReactNode;
80
+ /** Swap with the item above. Omit (or leave undefined on the first item) and
81
+ * the control renders disabled, so the column never changes width. */
82
+ onMoveUp?: () => void;
83
+ onMoveDown?: () => void;
84
+ onRemove?: () => void;
85
+ /** Names the item in the reorder/remove controls' announcements — "Remove
86
+ * Antwerp" rather than three identical "Remove" buttons. */
87
+ accessibilityName?: string;
88
+ /** Width of the role column. Default 74. */
89
+ roleWidth?: number;
90
+ }
91
+
92
+ /**
93
+ * One position in a {@link Sequence} — its marker, its role, its content, and
94
+ * the controls that move or drop it.
95
+ *
96
+ * The reorder affordance is a pair of BUTTONS, not a drag handle: dragging is
97
+ * invisible to the keyboard, unreachable on a screen reader, and fiddly on a
98
+ * phone, and a list of three-to-six positions does not need the expressiveness.
99
+ * They render even where they cannot act (first item, last item) so the row's
100
+ * right edge never shifts between items.
101
+ */
102
+ export function SequenceItem(props: SequenceItemProps) {
103
+ const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
104
+ const pos = useContext(SequenceItemContext);
105
+ const labels = useLoticsLocale().sequence;
106
+ const first = pos == null || pos.index === 0;
107
+ const last = pos == null || pos.index === pos.count - 1;
108
+ const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
109
+ return (
110
+ <View style={styles.item}>
111
+ {/* THE RAIL — a leading segment, the dot on the control line, then a
112
+ trailing segment that fills whatever height the content takes. The end
113
+ segments are transparent rather than absent so every item keeps the
114
+ same geometry and the dots stay on one x. */}
115
+ <View style={styles.rail}>
116
+ <View style={[styles.segment, { height: RAIL_LEAD }, first && styles.segmentHidden]} />
117
+ <View style={styles.dot} />
118
+ <View style={[styles.segment, styles.segmentFill, last && styles.segmentHidden]} />
119
+ </View>
120
+ <View style={styles.body}>
121
+ {role != null ? (
122
+ <View style={{ width: roleWidth }}>
123
+ <Text size="xs" color="muted">
124
+ {role}
125
+ </Text>
126
+ </View>
127
+ ) : null}
128
+ <View style={styles.content}>{children}</View>
129
+ <View style={styles.controls}>
130
+ <IconButton
131
+ icon="chevron-up"
132
+ tooltip={labels.moveUp}
133
+ accessibilityLabel={named(labels.moveUp)}
134
+ disabled={first || onMoveUp == null}
135
+ onPress={() => onMoveUp?.()}
136
+ />
137
+ <IconButton
138
+ icon="chevron-down"
139
+ tooltip={labels.moveDown}
140
+ accessibilityLabel={named(labels.moveDown)}
141
+ disabled={last || onMoveDown == null}
142
+ onPress={() => onMoveDown?.()}
143
+ />
144
+ <IconButton
145
+ icon="x"
146
+ tooltip={labels.remove}
147
+ accessibilityLabel={named(labels.remove)}
148
+ disabled={onRemove == null}
149
+ onPress={() => onRemove?.()}
150
+ />
151
+ </View>
152
+ </View>
153
+ </View>
154
+ );
155
+ }
156
+
157
+ const styles = StyleSheet.create({
158
+ item: { flexDirection: "row", alignItems: "stretch", gap: 10 },
159
+ rail: { width: RAIL_WIDTH, alignItems: "center" },
160
+ segment: { width: 1, backgroundColor: colors.zinc[300] },
161
+ // Fills the rest of the item, INCLUDING its bottom padding, so the line
162
+ // arrives at the next item's leading segment with nothing between them.
163
+ segmentFill: { flex: 1 },
164
+ segmentHidden: { backgroundColor: "transparent" },
165
+ dot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.zinc[400] },
166
+ // The item's own spacing — a gap on the parent would break the rail.
167
+ body: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4, paddingBottom: 6 },
168
+ content: { flexGrow: 1, flexBasis: 200, minWidth: 0, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 },
169
+ controls: { flexDirection: "row", alignItems: "center", gap: 2 },
170
+ });
@@ -30,7 +30,7 @@ export type ShareOrDownloadResult =
30
30
  | { delivered: "share"; count: number }
31
31
  | { delivered: "download"; count: number };
32
32
 
33
- /** "shared" = the sheet completed · "cancelled" = the user dismissed it ·
33
+ /** "shared" = the sheet completed, "cancelled" = the user dismissed it,
34
34
  * "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
35
35
  export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
36
36
 
@@ -65,7 +65,7 @@ export async function prepareShareFiles(
65
65
  /**
66
66
  * Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
67
67
  * user gesture (no awaited fetch before it) so iOS keeps the transient activation.
68
- * "cancelled" = user dismissed · "unsupported" = no Web Share; a real failure THROWS.
68
+ * "cancelled" = user dismissed, "unsupported" = no Web Share; a real failure THROWS.
69
69
  */
70
70
  export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
71
71
  const nav = typeof navigator !== "undefined" ? navigator : undefined;
@@ -4,7 +4,7 @@ import { Text } from "./text";
4
4
  import { useTooltip } from "./tooltip";
5
5
 
6
6
  export interface StepProgressProps {
7
- /** The stages: pass the NAMES (enables the built-in "4/7 · In" caption
7
+ /** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
8
8
  * and per-segment hover names) or a bare count (bar only). */
9
9
  steps: number | string[];
10
10
  /** 0-based index of the stage in progress; earlier segments render
@@ -17,7 +17,7 @@ export interface StepProgressProps {
17
17
  * uppercase eyebrow row above the bar, caption right. Omit at card
18
18
  * density where the context already names it. */
19
19
  title?: string;
20
- /** Caption override for when the current stage needs prose ("In production") instead of the derived "3/6 · SX". */
20
+ /** Caption override for when the current stage needs prose ("In production") instead of the derived "SX (3/6)". */
21
21
  label?: string;
22
22
  /** Tone for the caption — `danger` flags a stalled/overdue stage (a stuck
23
23
  * production order) in the FIXED caption spot, so the bar stays consistent
@@ -35,7 +35,7 @@ export interface StepProgressProps {
35
35
 
36
36
  /**
37
37
  * THE compact stage indicator — N equal segments filled through the current
38
- * stage, with a built-in "4/7 · In" caption when stages are named (hovering
38
+ * stage, with a built-in "In (4/7)" caption when stages are named (hovering
39
39
  * a segment names it). For *countable* stages an item walks through (a
40
40
  * production line, a checklist, a pipeline) shown at card/list density.
41
41
  *
@@ -55,8 +55,10 @@ export function StepProgress(props: StepProgressProps) {
55
55
  label ??
56
56
  (names
57
57
  ? isComplete
58
- ? `${count}/${count} · Complete`
59
- : `${Math.max(0, safe + 1)}/${count}${safe >= 0 ? ` · ${names[safe]}` : ""}`
58
+ ? `Complete (${count}/${count})`
59
+ : safe >= 0
60
+ ? `${names[safe]} (${safe + 1}/${count})`
61
+ : `0/${count}`
60
62
  : undefined);
61
63
 
62
64
  // `progressbar` IS right here — the segments are decoration over one quantity,
package/src/stepper.tsx CHANGED
@@ -68,7 +68,7 @@ export interface StepProps extends StepPositional {
68
68
  }
69
69
 
70
70
  /**
71
- * Progress through an ordered sequence — done · current · upcoming on a
71
+ * Progress through an ordered sequence — done, current, upcoming on a
72
72
  * connecting track (horizontal) or spine (vertical). The node encodes STATUS,
73
73
  * not identity: a filled dot once reached, a ring with a white centre at the
74
74
  * current step (pulsing when `live`), a faint ring for what's ahead, a check for
package/src/task.tsx CHANGED
@@ -14,7 +14,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
14
14
  * <TaskList>
15
15
  * <TaskItem>
16
16
  * <TaskStatus><CheckCircle …/></TaskStatus>
17
- * <TaskTitle><InlineTextInput variant="cell" struck={done} …/></TaskTitle>
17
+ * <TaskTitle><InlineTextInput variant="bare" struck={done} …/></TaskTitle>
18
18
  * <TaskActions><ActionMenu …/></TaskActions>
19
19
  * <TaskCaption>Waiting on the signed copy</TaskCaption>
20
20
  * <TaskSubRow label="Portal account"><InlineTextInput …/></TaskSubRow>
@@ -81,7 +81,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
81
81
  * **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
82
82
  * sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
83
83
  * title. Prose given a label reads as a field nobody can edit. Which is why a sub-row annotates
84
- * like a record's field does — `description` · `warning` · `error`, the one anatomy in
84
+ * like a record's field does — `description`, `warning`, `error`, the one anatomy in
85
85
  * `field_annotations` — so a fault in ONE field says so on that field instead of being demoted
86
86
  * into a sentence about the whole row.
87
87
  *
@@ -131,11 +131,11 @@ const TaskListContext = createContext<TaskListContextValue>({
131
131
  */
132
132
  const SUB_VALUE_MIN = 200;
133
133
  /**
134
- * How far a `variant="cell"` control insets its own text — the kit's `CONTROL_TEXT_INSET` under
134
+ * How far an inline control insets its own text — the kit's `CONTROL_TEXT_INSET` under
135
135
  * this family's name, because a task surface reaches for it constantly. Everything that hangs
136
136
  * beneath a row — the caption, a sub-row's label, the detail block — adds the same, so it lines
137
137
  * up with the WORDS rather than with the editor's invisible box. A STRING title gets it from
138
- * `TaskTitle`; only a custom title NODE that is not a `variant="cell"` control has to add it, or
138
+ * `TaskTitle`; only a custom title NODE that is not an inline control has to add it, or
139
139
  * it sits out of line with the editable titles around it.
140
140
  */
141
141
  export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
@@ -282,7 +282,7 @@ type TaskTitleProps =
282
282
  | { children: ReactNode; struck?: never };
283
283
 
284
284
  /**
285
- * The task's identity — an inline editor (`variant="cell"`), or the title text itself.
285
+ * The task's identity — an inline editor, or the title text itself.
286
286
  *
287
287
  * Pass a STRING and the title renders on the cell inset with the band's slack above and below
288
288
  * it, so it lines up with the editable titles around it AND a title that wraps keeps its first
@@ -379,7 +379,7 @@ interface TaskSubRowProps extends FieldAnnotationProps {
379
379
  * own line under the label rather than compressing to a few characters, and on a wide one it
380
380
  * FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
381
381
  *
382
- * It annotates exactly like a `DetailRow` — `description` · `warning` · `error`, one shared
382
+ * It annotates exactly like a `DetailRow` — `description`, `warning`, `error`, one shared
383
383
  * anatomy (`field_annotations`). A field-level fault says so ON THE FIELD; demoting it into a
384
384
  * `TaskCaption`, which is a sentence about the ROW, says something different.
385
385
  *