@lotics/ui 25.1.0 → 26.1.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/AGENTS.md CHANGED
@@ -27,13 +27,23 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
27
27
  license to hand-roll.
28
28
  - **One canonical component per data role** (member → `MemberChip`, select → `OptionBadge`,
29
29
  files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
30
+ - **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
31
+ `primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
32
+ extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
33
+ the one place it doesn't. → [composition.md §The add-placement law](./docs/composition.md).
34
+ - **`EmptyState` carries NO verb, and a FAILED read is not an empty one.** Four region states,
35
+ picked by what the region can ASSERT: `Skeleton`/`Loading` in flight → **`ErrorState`**
36
+ (`message`/`detail`/`onRetry`) on failure → `EmptyState` (succeeded, found nothing) →
37
+ `CompletionState` on done. An alert glyph inside an empty state claims the read succeeded when
38
+ nothing is known. → [catalog.md §Status / feedback](./docs/catalog.md).
30
39
  - **Progress: rows of WORK vs positions of ONE thing.** N items ticked in any order, every row
31
40
  the same shape → `task` (`TaskList`). ONE record walking ordered stages where the stage decides
32
41
  which fields, conditions and act are even offered → **`pipeline`** (`Pipeline` +
33
42
  `PipelineStage`, over `stepper`) — worked in `examples/tpl_record.tsx` § Progress, which it took
34
43
  over FROM a per-desk checklist. Rendering positions as a checklist forces every row to carry
35
44
  every control and never says where the record sits; rendering work items as a pipeline implies
36
- an order that is not there.
45
+ an order that is not there. A long ladder whose stages each own one value goes dense with
46
+ `PipelineStage.trailing` rather than a hand-rolled title row — see `docs/catalog.md`.
37
47
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
38
48
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
39
49
  never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
package/MIGRATION.md CHANGED
@@ -4,6 +4,57 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 26.0.0 — a section's ADD moves to its heading row; `EmptyState` loses its verb
8
+
9
+ **`EmptyState.action` is REMOVED — no replacement on that component.** Two different things
10
+ were going into it, and neither belonged:
11
+
12
+ - **"Create the first one"** gave the section's add a SECOND home that exists only while the
13
+ list is empty, so the verb jumped elsewhere the moment the first row landed — and on a long
14
+ list the other copy sat off-screen below the rows. The add now lives on the section's heading
15
+ row, at the right, rendered whether the collection is empty or full (`AGENTS.md` iron rules,
16
+ `composition.md` § The add-placement law).
17
+ - **"The read failed, try again"** was a failure wearing an empty state's clothes. An empty
18
+ state asserts the read SUCCEEDED and found nothing; a failed read does not know whether there
19
+ is anything. That is now **`ErrorState`** (NEW, `@lotics/ui/error_state`).
20
+
21
+ ```tsx
22
+ // BEFORE — the add lived in the empty state, and again under the rows
23
+ <SectionHeading><SectionHeadingTitle>Fees</SectionHeadingTitle></SectionHeading>
24
+ {fees.length === 0
25
+ ? <EmptyState message="No fees" action={<Button title="Add fee" onPress={addFee} />} />
26
+ : <Table …/>}
27
+ {fees.length > 0 ? <Button title="Add fee" onPress={addFee} /> : null}
28
+
29
+ // AFTER — one add, one place, never moves
30
+ <SectionHeading>
31
+ <SectionHeadingTitle>Fees</SectionHeadingTitle>
32
+ <Button title="Add fee" color="primary" onPress={addFee} />
33
+ </SectionHeading>
34
+ {fees.length === 0 ? <EmptyState message="No fees" /> : <Table …/>}
35
+ ```
36
+
37
+ ```tsx
38
+ // BEFORE — a failure rendered as an empty, with a hand-written retry
39
+ <EmptyState icon="triangle-alert" message="Couldn't load trips" hint={err}
40
+ action={<Button title="Reload" onPress={() => q.refetch()} />} />
41
+
42
+ // AFTER — its own state; the kit words the button (locale `errorState.retry`)
43
+ <ErrorState message="Couldn't load trips" detail={err} onRetry={() => q.refetch()} />
44
+ ```
45
+
46
+ **Per call site:** an `action` that CREATED → delete it, put the button in the heading. One that
47
+ RECOVERED from a failed read → switch the whole component to `ErrorState` and drop the label.
48
+ One that cleared filters on a no-results empty → delete it; that empty is hint-only and the
49
+ filter controls carry their own clear.
50
+
51
+ **Locale packs** gain `errorState: { retry }`. A custom pack won't compile until it's filled —
52
+ which is the point.
53
+
54
+ **Also new (additive, no migration):** `Step.headHeight` and `PipelineStage.trailing` — a stage
55
+ whose title row carries an inline control. Hand-rolling that row leaves the marker centred on a
56
+ text line while the label centres in the taller row, so every label reads ~10px low.
57
+
7
58
  ## 25.0.0 — `ReferenceField` edits in place; `onRemove` becomes `onClear`
8
59
 
9
60
  **`onRemove` is now `onClear` — a pure rename, identical behaviour.** The act was always
package/docs/catalog.md CHANGED
@@ -236,8 +236,10 @@ reference), `InfoPopover` (the ⓘ explainer).
236
236
 
237
237
  ### Status / feedback
238
238
 
239
- `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`, `CompletionState`,
240
- `ActivityIndicator` / `Loading`, `Skeleton`.
239
+ `Badge` / `StatusBadge`, `Callout` (a failure INSIDE a flow), and the four REGION states a read
240
+ passes through — `Skeleton` / `Loading` (in flight) → `ErrorState` (failed) → `EmptyState`
241
+ (succeeded, nothing) → `CompletionState` (done). Pick by what the region can ASSERT: an empty
242
+ says the read succeeded and found nothing, a failed read knows neither.
241
243
 
242
244
  ### Files
243
245
 
@@ -411,7 +413,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
411
413
  `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
412
414
  same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
413
415
  title of a named group inside a section (same `info` ⓘ affordance as the section title) —
414
- heading-row siblings ride its right edge. `DialogSectionHeadingTitle` is the `####`
416
+ heading-row siblings ride its right edge. **A section's ADD is one of those siblings** — a
417
+ `primary` `Button` beside the title, rendered whether the collection is empty or full, never
418
+ under the rows it extends and never repeated in the `EmptyState`
419
+ (composition.md § The add-placement law). `DialogSectionHeadingTitle` is the `####`
415
420
  md-semibold rung with the SAME `icon`/`description`/`info` slots as the section title, so a
416
421
  dialog surface loses only the type size, never an affordance. The heading ramp is FIXED:
417
422
  `#` xxl / `##` xl / `###` lg / `####` md, no size props.
@@ -553,8 +558,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
553
558
  - **`callout`** — `Callout`, `CalloutTitle`, `CalloutText`, `CalloutActions` (`tone`
554
559
  info|success|warning|error|neutral): inline status band — a MESSAGE (`warning`/`error`
555
560
  are ARIA `alert`s). For a form / nested content surface use **`Inset`**, never a Callout.
556
- - **`empty_state`** — `EmptyState`: centered placeholder for an empty list/filter result
557
- `message` + `hint`, optional `icon` anchor and `action` CTA.
561
+ - **`empty_state`** — `EmptyState`: centered placeholder for a read that SUCCEEDED and found
562
+ nothing — `message` + `hint` + an optional `icon` anchor, and **no verb at all**. It cannot hold
563
+ the section's add (that lives on the heading row, where it does not move — § The add-placement
564
+ law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
565
+ their own clear, and a FAILED read is `ErrorState`, not this.
566
+ - **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
567
+ + `onRetry` (the kit renders the button and words it from the locale pack, so "try again" reads
568
+ the same everywhere). The fourth of the region states: `Skeleton`/`Loading` in flight → this on
569
+ failure → `EmptyState` on nothing → `CompletionState` on done. Rendering a failure as an empty
570
+ asserts the read succeeded and found nothing, when nothing is known. For a failure INSIDE a
571
+ flow (a form that won't save) use `Callout tone="error"`; this is for a region with no content
572
+ to show, where a tinted strip leaves the area collapsed.
558
573
  - **`completion_state`** — `CompletionState`: the "all done" terminal state.
559
574
  - **`skeleton`** — `Skeleton`: loading placeholder blocks.
560
575
  - **`loading`** — `Loading`: the centered indeterminate loading state (composes
@@ -864,7 +879,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
864
879
  derived value that rides along read-only); `multiline` for an address or an account block;
865
880
  `type: "date"` swaps the draft's text input for a `DatePicker` and formats the read value —
866
881
  the fact's `value` stays the canonical ISO string on both sides, so a date is never edited as
867
- free text and the format ambiguity never reaches the record.
882
+ free text and the format ambiguity never reaches the record; `action` hangs a verb ABOUT the
883
+ value on it (an `InlineButton` — "look this tax id up"), rendered AT REST only, because an
884
+ action writes the RECORD while a draft holds values seeded before it ran — fire one mid-draft
885
+ and Save writes the pre-action values back over what it just fetched.
886
+ The peek uses the popover's OWN anatomy: `PopoverHeader` for the identity, a scrolling body,
887
+ and a **pinned `PopoverFooter`** for the verbs. A long block therefore scrolls with its actions
888
+ still reachable — the earlier hand-rolled footer was a body child and scrolled away, hiding its
889
+ own Save.
868
890
  `Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
869
891
  which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
870
892
  `before_update` sees the real edit). This is what lets a peek hold editors at all: a
@@ -1175,6 +1197,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1175
1197
  moving, one stage live, different controls per stage; `task` is N identical rows ticked in any
1176
1198
  order. `PipelineNote` is a condition ON a stage (never a `Callout` above the run);
1177
1199
  `PipelineField` stays editable on a PASSED stage, which is what makes a mis-entry fixable.
1200
+ A field stacks UNDER the title (two lines per stage); on a ladder long enough that this
1201
+ pushes the run past a screenful, **`PipelineStage.trailing`** puts ONE value on the title's
1202
+ own row instead. Never hand-roll that row: a control is twice a text line's height, so the
1203
+ marker — which centres on the first row — reads half the difference too high against it.
1204
+ `trailing` fixes the row at `INLINE_CONTROL_HEIGHT` and tells the `Step`, so the ladder keeps
1205
+ one rhythm whatever the value is. An ACT still belongs in `PipelineActions`.
1178
1206
  Worked screen: `examples/tpl_record.tsx` § Progress — the desks (Sales → Operations →
1179
1207
  Accounting) ARE the stages, each owning the facts it stamps and, on the live one, the act
1180
1208
  that leaves it. A `PipelineStage` is deliberately NOT pressable: its body holds the controls.
@@ -1185,6 +1213,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1185
1213
  switcher; the guided-run / agent-feed primitive. It is a **`list` of `listitem`s** with
1186
1214
  `aria-current="step"` on the live one — a sequence of NAMED positions whose steps carry
1187
1215
  their own content is a list, never a `progressbar`.
1216
+ VERTICAL: the marker centres on the content's FIRST ROW, assumed to be one line of `sm` text.
1217
+ Put anything taller on that row — an inline editor beside the label — and pass
1218
+ **`Step.headHeight`** (`INLINE_CONTROL_HEIGHT` for an editor) or the marker stays pinned to
1219
+ the text line while the label centres in the taller row. Prefer `PipelineStage.trailing`,
1220
+ which does this for you.
1188
1221
 
1189
1222
  ### Files
1190
1223
 
@@ -44,9 +44,9 @@ restyle a heading level per-page.
44
44
  numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
45
45
  - **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
46
46
  (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
47
- at the column edge; `SectionHeadingMeta` and any VIEW control ride the heading row's right edge
48
- (the title's `flex: 1` pushes them) — but a verb that CHANGES the content does not: see the
49
- action-placement law below. The page column is a **`SectionStack`** — it owns the
47
+ at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
48
+ heading row's right edge (the title's `flex: 1` pushes them) — see the add-placement law
49
+ below. The page column is a **`SectionStack`** — it owns the
50
50
  between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
51
51
  null/false children are skipped so a conditional section never leaves a stray hairline). The
52
52
  `Divider` NEVER goes directly under a heading — that orphans the title from its own content.
@@ -101,6 +101,30 @@ restyle a heading level per-page.
101
101
  - **Gate header** — a `Dialog` uses `DialogHeaderTitle`; a popover form uses
102
102
  `Text size="sm" weight="semibold"` + an optional xs muted subtitle.
103
103
 
104
+ ### The add-placement law — a section's ADD rides its heading row, right edge
105
+
106
+ **Add files / Add fee / Add member / New line — the verb that EXTENDS a section's collection
107
+ sits on that section's heading row, at the right, and nowhere else.** One `Button` as a sibling
108
+ of `SectionHeadingTitle` (whose `flex: 1` pushes it there); the same at subsection and card
109
+ altitude (`SubsectionHeading`, `CardHeader`).
110
+
111
+ The rule is about a position that does not MOVE. Put the add under the register it extends and
112
+ where the reader looks for it depends on how many rows there already are — past a screenful the
113
+ verb is off-screen, and on an empty list there is no last row to sit under, so it has to become
114
+ a second button inside the `EmptyState`. That is one verb with two homes, neither findable
115
+ without scanning. The heading row is the section's control line — it already carries the meta
116
+ and the view controls — so the add belongs on it, in the same spot whether the section holds
117
+ nought or forty.
118
+
119
+ - **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
120
+ `primary`. Don't drop to `secondary` because the list is full — an add that changes weight
121
+ with row count is the fault, not the cure.
122
+ - **It renders unconditionally**, empty or not. A heading whose CTA appears only once there is
123
+ data cannot be used to create the first row.
124
+ - **The `EmptyState` then takes NO `action`** — see § Empty states.
125
+ - **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
126
+ selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
127
+
104
128
  ## Period filters for time-constrained data
105
129
 
106
130
  Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
@@ -832,13 +856,20 @@ one exists).
832
856
 
833
857
  ### Empty states — confirm, orient, one action
834
858
 
835
- An empty region has three jobs (NN/g): confirm this is EMPTY (not loading, not broken), say
836
- what belongs here, and offer ONE next action — the `EmptyState` props are this anatomy
837
- (`message` = what's empty, `hint` = what to do about it, `action` = the one CTA). The three
838
- empties get DIFFERENT copy:
859
+ An empty region has three jobs (NN/g): confirm this is EMPTY not loading, not broken — and
860
+ say what belongs here. The `EmptyState` props are that anatomy and nothing more (`message` =
861
+ what's empty, `hint` = what to do about it). "Not broken" is why the failed read has its own
862
+ component: an alert glyph inside an empty state says both at once and means neither.
863
+
864
+ **`EmptyState` carries NO verb.** The add is on the heading row (§ The add-placement law) and
865
+ duplicating it here would put two buttons for one act in view and make the add jump the moment
866
+ the first row lands. A read that FAILED is not an empty one — it does not know whether there is
867
+ anything — so it gets `ErrorState` (`message` + `detail` + `onRetry`), never an empty state
868
+ wearing an alert glyph. The three empties get DIFFERENT copy:
839
869
 
840
- - **First use** → teach + invite: message names what will live here, `action` creates the first
841
- one ("Chưa khoản phí" + "Thêm khoản thu/chi cho " + a Thêm phí button).
870
+ - **First use** → teach + invite: message names what will live here, and the CTA — the
871
+ heading's, or `action` where there is no heading creates the first one ("Chưa khoản phí"
872
+ + "Thêm khoản thu/chi cho lô").
842
873
  - **User cleared it** → stay quiet: the user knows why it's empty; confirmation only, no tutorial.
843
874
  - **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
844
875
  'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
package/docs/templates.md CHANGED
@@ -309,8 +309,9 @@ billing, and quick-capture templates. Top → bottom:
309
309
  just Documents) — a drag or Ctrl/Cmd+V ANYWHERE on the record (`disabled` while the intake
310
310
  dialog runs so a paste can't start a second one) — all landing in ONE `intakeFiles` fork
311
311
  dialog. The Documents section therefore carries NO drop target of its own (the whole-record
312
- one covers it), only its Add-files CTA and a muted `SectionHeadingTitle description` ("Drag,
313
- paste, or click to add files") that keeps the otherwise-invisible paths discoverable.
312
+ one covers it), only its Add-files CTA on the heading row's right edge and a muted
313
+ `SectionHeadingTitle description` ("Drag, paste, or click to add files") that keeps the
314
+ otherwise-invisible paths discoverable.
314
315
  Every row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
315
316
  fills it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
316
317
  (mixed footprints misalign the identity column). The desk holds what ARRIVES; generation
@@ -332,7 +333,9 @@ billing, and quick-capture templates. Top → bottom:
332
333
  a `priority`-annotated register `Table` (Fee, Type, Party, Amount, Status — status in
333
334
  plain ink, danger only when overdue) → EVERY row opens a right-docked entity `Drawer`
334
335
  (◀ ▶ + position stepping) with all fields inline-editable and a confirmed Remove in the
335
- `DrawerFooter`. "Add fee" is create-then-refine: a blank fee opens straight in the drawer.
336
+ `DrawerFooter`. "Add fee" rides the section heading's right edge the one place it does not
337
+ move as rows arrive — and is create-then-refine: a blank fee opens straight in the drawer. The
338
+ empty state carries no button of its own, because that one is already on screen.
336
339
  - **Billing — a REGISTER, the same shape as Fees.** Both sections are a list of money items
337
340
  where each item has detail and a per-item act, so both are a compact `Table` whose row opens
338
341
  its FULL detail in a right-docked `Drawer` (◀ ▶ step the invoices) — the drill-down law.
@@ -2030,6 +2030,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2030
2030
  {/* The standard files-section affordance line — drag / paste / click
2031
2031
  stay discoverable even though the drop target is the whole record. */}
2032
2032
  <SectionHeadingTitle description="Everything that ARRIVED on this record. Drag, paste, or click to add.">Files</SectionHeadingTitle>
2033
+ {/* The section's ADD, on the heading row — see the Fees section for
2034
+ the rule. PENDING until the user chooses: saving is a decision the
2035
+ dialog asks for (save only / run a task), never a side effect of
2036
+ picking — closing the dialog discards. */}
2037
+ <Button
2038
+ title="Add files"
2039
+ color="primary"
2040
+ onPress={() => {
2041
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
2042
+ }}
2043
+ />
2033
2044
  </SectionHeading>
2034
2045
  <Table
2035
2046
  columns={DOC_COLUMNS}
@@ -2072,19 +2083,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2072
2083
  </TableRow>
2073
2084
  ))}
2074
2085
  </Table>
2075
- {/* the Add, under the register it extends — see the Fees section for
2076
- why it is not in the heading. PENDING until the user chooses:
2077
- saving is a decision the dialog asks for (save only / run a task),
2078
- never a side effect of picking — closing the dialog discards. */}
2079
- <View style={{ flexDirection: "row" }}>
2080
- <Button
2081
- title="Add files"
2082
- color="primary"
2083
- onPress={() => {
2084
- void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
2085
- }}
2086
- />
2087
- </View>
2088
2086
  </Section>
2089
2087
  </View>
2090
2088
 
@@ -2330,6 +2328,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2330
2328
  <Section>
2331
2329
  <SectionHeading>
2332
2330
  <SectionHeadingTitle description="Every fee the order incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
2331
+ {/* THE SECTION'S ADD RIDES THE HEADING ROW, right edge — the one
2332
+ place it can sit that does not MOVE. Under the register it sat
2333
+ below the last row, so where a reader looks for "how do I add
2334
+ one" depended on how many there already were: past a screenful
2335
+ the verb is off-screen entirely, and on an empty list there is no
2336
+ last row to sit under, so it had to be a SECOND button inside the
2337
+ `EmptyState`. One verb, two renderings, neither findable without
2338
+ scanning. The heading row is the section's control line — it
2339
+ already carries `SectionHeadingMeta` and the title grows to push
2340
+ its siblings right — so the add belongs on it, in the same spot
2341
+ whether the list holds nought or forty. */}
2342
+ <Button title="Add fee" color="primary" onPress={addFee} />
2333
2343
  </SectionHeading>
2334
2344
  <SummaryLine
2335
2345
  items={[
@@ -2338,8 +2348,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2338
2348
  { label: "to pay", value: fees.filter((f) => f.direction === "cost" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true, tone: fees.some((f) => f.direction === "cost" && feeOverdue(f)) ? "warning" : undefined },
2339
2349
  ]}
2340
2350
  />
2351
+ {/* The empty state takes NO `action` — the heading's Add is the only
2352
+ one, and it is already on screen. Repeating the section's verb here
2353
+ puts two buttons for one act in view at once, and makes the add MOVE
2354
+ the moment the first row lands. The empty state says what the
2355
+ section holds; the heading says how to fill it. */}
2341
2356
  {fees.length === 0 ? (
2342
- <EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in." action={<Button title="Add fee" color="primary" onPress={addFee} />} />
2357
+ <EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in." />
2343
2358
  ) : (
2344
2359
  <Table columns={FEE_COLUMNS}>
2345
2360
  {fees.map((f) => {
@@ -2457,21 +2472,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2457
2472
  })}
2458
2473
  </Table>
2459
2474
  )}
2460
- {/* THE ADD SITS WHERE ITS RESULT APPEARS — under the last row, on the
2461
- list's own left edge. In the heading it sat ABOVE the thing it
2462
- extends and at a different altitude from it, and it competed with
2463
- the title for the one line that names the section. Secondary and
2464
- left, and PRIMARY — in the empty state too, which is what makes it
2465
- consistent. A section's add is the act that section offers, so it
2466
- carries the section's weight; a lone `secondary` button reads as
2467
- though the real action were somewhere else. One verb keeps ONE
2468
- weight either way: the fault to avoid is an add that is primary on
2469
- an empty list and secondary on a full one. */}
2470
- {fees.length > 0 ? (
2471
- <View style={{ flexDirection: "row" }}>
2472
- <Button title="Add fee" color="primary" onPress={addFee} />
2473
- </View>
2474
- ) : null}
2475
2475
  </Section>
2476
2476
  </View>
2477
2477
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "25.1.0",
3
+ "version": "26.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -63,6 +63,7 @@
63
63
  "./kpi_strip": "./src/kpi_strip.tsx",
64
64
  "./summary_line": "./src/summary_line.tsx",
65
65
  "./empty_state": "./src/empty_state.tsx",
66
+ "./error_state": "./src/error_state.tsx",
66
67
  "./format_date": "./src/format_date.ts",
67
68
  "./format_money": "./src/format_money.ts",
68
69
  "./calendar": "./src/calendar/index.ts",
package/src/callout.tsx CHANGED
@@ -40,7 +40,10 @@ const TONES: Record<CalloutTone, ToneStyle> = {
40
40
  /**
41
41
  * An inline callout — a tinted, bordered box carrying a short status message
42
42
  * (info / success / warning / error / neutral). Use it inline in a flow: form
43
- * feedback, a heads-up, an actionable empty state. For a blocking, dismissible
43
+ * feedback, a heads-up, a failure INSIDE a flow. NOT for a dead region — a list
44
+ * that came back empty is `EmptyState`, one that FAILED is `ErrorState`; a
45
+ * tinted strip there leaves the area collapsed and reading as a render bug.
46
+ * For a blocking, dismissible
44
47
  * prompt use `Alert`; for a one-word status use `Badge`. The tone is carried by
45
48
  * the icon + tint + border (never color alone — the icon and text stay legible),
46
49
  * so it reads on a glance and meets contrast on the light tint.
@@ -1,4 +1,3 @@
1
- import type { ReactNode } from "react";
2
1
  import { View, StyleSheet } from "react-native";
3
2
  import { Text } from "./text";
4
3
  import { Icon, type IconName } from "./icon";
@@ -12,16 +11,28 @@ interface EmptyStateProps {
12
11
  /** Optional Lucide glyph centered above the message — a visual anchor so the
13
12
  * empty region reads as a deliberate state, not two lone lines of muted text. */
14
13
  icon?: IconName;
15
- /** Optional call-to-action below the text (e.g. a Button) for an actionable
16
- * empty state ("no records yet → create one"). */
17
- action?: ReactNode;
18
14
  }
19
15
 
20
16
  /**
21
17
  * Centered placeholder for an empty list/filter result. Generous vertical
22
18
  * space on purpose — an empty region that collapses to nothing reads as a
23
- * rendering bug, not a state. Pass `icon` for a visual anchor and `action`
24
- * for a CTA when the empty state is actionable.
19
+ * rendering bug, not a state.
20
+ *
21
+ * IT CARRIES NO VERB — the prop is gone, not narrowed. This took an
22
+ * `action?: ReactNode` and two different things went into it, both wrong here:
23
+ *
24
+ * - **"Create the first one"** put the section's add in a SECOND place that only
25
+ * exists while the list is empty, so the verb jumped elsewhere the moment the
26
+ * first row landed. It belongs on the section's heading row, where it does not
27
+ * move (composition.md § The add-placement law).
28
+ * - **"The read failed, try again"** was a failure wearing an empty state's
29
+ * clothes. That asserts the read SUCCEEDED and found nothing, when the truth
30
+ * is that nothing is known — use `ErrorState`. Retrying a true empty just
31
+ * returns the same nothing.
32
+ *
33
+ * What is left says one thing and offers nothing to press: this region is empty,
34
+ * and here is what would live in it. A no-results empty is hint-only too — the
35
+ * filters that emptied it carry their own clear.
25
36
  */
26
37
  export function EmptyState(props: EmptyStateProps) {
27
38
  return (
@@ -39,7 +50,6 @@ export function EmptyState(props: EmptyStateProps) {
39
50
  {props.hint}
40
51
  </Text>
41
52
  ) : null}
42
- {props.action ? <View style={styles.action}>{props.action}</View> : null}
43
53
  </View>
44
54
  );
45
55
  }
@@ -53,7 +63,4 @@ const styles = StyleSheet.create({
53
63
  icon: {
54
64
  marginBottom: 4,
55
65
  },
56
- action: {
57
- marginTop: 8,
58
- },
59
66
  });
@@ -0,0 +1,75 @@
1
+ import { View, StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Button } from "./button";
4
+ import { Icon } from "./icon";
5
+ import { solid } from "./colors";
6
+ import { useLoticsLocale } from "./locale";
7
+
8
+ export interface ErrorStateProps {
9
+ /** What failed, in plain language ("Couldn't load customers"). */
10
+ message: string;
11
+ /** The cause when the system knows it — the server's own message. Never a
12
+ * stack trace or an error code alone: neither tells the reader what to do. */
13
+ detail?: string;
14
+ /** Re-run the read. Omitted for a failure the reader cannot retry (denied,
15
+ * not found) — the kit renders the button and words it from the locale pack,
16
+ * so "try again" is phrased the same everywhere instead of per app. */
17
+ onRetry?: () => void;
18
+ }
19
+
20
+ /**
21
+ * The read FAILED — the region-scale sibling of `EmptyState`, and the missing
22
+ * fourth in the family (`Skeleton`/`Loading` in flight → this on failure →
23
+ * `EmptyState` on nothing → `CompletionState` on done).
24
+ *
25
+ * IT EXISTS BECAUSE EMPTY AND FAILED ARE DIFFERENT ASSERTIONS. "No fees on this
26
+ * order" says the read succeeded and there is nothing; a failed read does not
27
+ * know whether there is anything. Apps were rendering the failure through
28
+ * `EmptyState` with an alert glyph and a hand-written retry — which states a
29
+ * fact the system does not have (unknown drawn as none), and left every app
30
+ * inventing its own wording for the same verb. A retry on a TRUE empty is
31
+ * equally wrong: running it again returns the same nothing.
32
+ *
33
+ * Use `Callout tone="error"` instead for a failure inside a flow — a form that
34
+ * would not save. This is for a whole REGION that has no content to show, where
35
+ * a tinted strip would leave the area collapsed and reading as a render bug.
36
+ */
37
+ export function ErrorState(props: ErrorStateProps) {
38
+ const words = useLoticsLocale();
39
+ return (
40
+ <View style={styles.container}>
41
+ <View style={styles.icon}>
42
+ <Icon name="triangle-alert" size={28} color={solid("red")} />
43
+ </View>
44
+ <Text size="sm" color="muted">
45
+ {props.message}
46
+ </Text>
47
+ {props.detail ? (
48
+ <Text size="xs" color="muted">
49
+ {props.detail}
50
+ </Text>
51
+ ) : null}
52
+ {props.onRetry ? (
53
+ <View style={styles.action}>
54
+ <Button title={words.errorState.retry} color="secondary" onPress={props.onRetry} />
55
+ </View>
56
+ ) : null}
57
+ </View>
58
+ );
59
+ }
60
+
61
+ // The same rhythm as `EmptyState` — the two swap into one another as a read
62
+ // settles, and a region that changed height on failure would jump the page.
63
+ const styles = StyleSheet.create({
64
+ container: {
65
+ paddingVertical: 48,
66
+ alignItems: "center",
67
+ gap: 4,
68
+ },
69
+ icon: {
70
+ marginBottom: 4,
71
+ },
72
+ action: {
73
+ marginTop: 8,
74
+ },
75
+ });
package/src/locale.tsx CHANGED
@@ -70,6 +70,9 @@ export interface LoticsLocale {
70
70
  ledger: { rowDetails: (label: string) => string };
71
71
  /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
72
72
  sectionHeading: { info: string };
73
+ /** `ErrorState`'s retry button. The kit owns the wording so "try again" is
74
+ * phrased identically everywhere instead of hand-written per app. */
75
+ errorState: { retry: string };
73
76
  /** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
74
77
  chip: { remove: string };
75
78
  /** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
@@ -234,6 +237,7 @@ export const en: LoticsLocale = {
234
237
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
235
238
  ledger: { rowDetails: (label) => `${label} details` },
236
239
  sectionHeading: { info: "About this data" },
240
+ errorState: { retry: "Try again" },
237
241
  chip: { remove: "Remove" },
238
242
  sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
239
243
  trendFooter: { up: "Up", down: "Down" },
@@ -393,6 +397,7 @@ export const vi: LoticsLocale = {
393
397
  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" },
394
398
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
395
399
  sectionHeading: { info: "Giải thích dữ liệu" },
400
+ errorState: { retry: "Thử lại" },
396
401
  chip: { remove: "Xóa" },
397
402
  sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
398
403
  trendFooter: { up: "Tăng", down: "Giảm" },
package/src/pipeline.tsx CHANGED
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Stepper, Step, type StepPositional, type StepStatus } from "./stepper";
5
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
6
 
6
7
  export interface PipelineProps {
7
8
  children?: ReactNode;
@@ -40,11 +41,16 @@ export interface PipelineProps {
40
41
  * editable behind you, or the only way to fix a mis-entry is direct table
41
42
  * access.
42
43
  *
44
+ * A stage's values stack UNDER its title by default (`PipelineField`), which
45
+ * costs two lines each. On a ladder long enough that this pushes the run past a
46
+ * screenful, `trailing` puts one value on the title's own row instead.
47
+ *
43
48
  * ```tsx
44
49
  * <Pipeline>
45
50
  * <PipelineStage status="done" title="Submitted">
46
51
  * <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
47
52
  * </PipelineStage>
53
+ * <PipelineStage status="done" title="Received" trailing={<InlineDatePicker … />} />
48
54
  * <PipelineStage status="current" title="In review" meta="Waiting 3d, Ops">
49
55
  * <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
50
56
  * <PipelineActions>
@@ -71,6 +77,18 @@ export interface PipelineStageProps extends StepPositional {
71
77
  /** A muted line under the title: how long it has sat here, whose desk it is on.
72
78
  * Prose, not a value the reader sets. */
73
79
  meta?: string;
80
+ /**
81
+ * ONE value the stage owns, on the TITLE's row rather than stacked under it —
82
+ * the date a milestone was reached, its reference number. Use it when the
83
+ * ladder is long enough that a `PipelineField` per stage costs two lines each
84
+ * and pushes the whole run past a screenful; use `PipelineField` when the value
85
+ * needs a label to be read, or when there is more than one.
86
+ *
87
+ * The row is fixed at `INLINE_CONTROL_HEIGHT` whatever you put here, so the
88
+ * marker lines up with the title and the stages keep one rhythm down the spine.
89
+ * An ACT still belongs in `PipelineActions` — this is for a value.
90
+ */
91
+ trailing?: ReactNode;
74
92
  /** The stage's own body — `PipelineNote`, `PipelineField`, `PipelineActions`,
75
93
  * or anything else. A stage with no body renders as its title alone, which is
76
94
  * what an unreached stage should be. */
@@ -78,27 +96,49 @@ export interface PipelineStageProps extends StepPositional {
78
96
  accessibilityLabel?: string;
79
97
  }
80
98
 
81
- /** One milestone. Its title reads by STATUS — the current one in full ink and
82
- * medium weight, everything else muted — so the eye lands on the stage that is
83
- * live without reading a word.
99
+ /** One milestone. Its title reads by STATUS — the LIVE one (`current`, or the
100
+ * terminal `complete`) in full ink and medium weight, everything else muted —
101
+ * so the eye lands on where the record sits without reading a word.
84
102
  *
85
103
  * Deliberately NOT pressable. `Step` can be (a wizard whose steps navigate),
86
104
  * but a pipeline stage is a workspace, not a destination — its body already
87
105
  * holds the controls, and a press target wrapping them would swallow their taps. */
88
106
  export function PipelineStage(props: PipelineStageProps) {
89
- const { status, title, meta, children, accessibilityLabel, ...positional } = props;
90
- const isCurrent = status === "current";
107
+ const { status, title, meta, trailing, children, accessibilityLabel, ...positional } = props;
108
+ // `complete` is the terminal stage REACHED — where a finished record sits, not
109
+ // one it walked past. Muting it like an unreached stage leaves a completed run
110
+ // with nothing in full ink, so the eye has no landing point and the last thing
111
+ // that happened reads as the thing that hasn't.
112
+ const isLive = status === "current" || status === "complete";
113
+ const titleText = (
114
+ <Text size="sm" color={isLive ? "default" : "muted"} weight={isLive ? "medium" : "regular"}>
115
+ {title}
116
+ </Text>
117
+ );
91
118
  return (
92
119
  <Step
93
120
  status={status}
94
121
  accessibilityLabel={accessibilityLabel ?? title}
122
+ // The marker centres on the FIRST ROW, so it has to be told when that row
123
+ // is a control band and not a line of text — otherwise it stays pinned to
124
+ // the text and every title reads low by half the difference.
125
+ headHeight={trailing != null ? INLINE_CONTROL_HEIGHT : undefined}
95
126
  {...positional}
96
127
  >
97
128
  <View style={{ gap: 6 }}>
98
129
  <View style={{ gap: 2 }}>
99
- <Text size="sm" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "medium" : "regular"}>
100
- {title}
101
- </Text>
130
+ {trailing != null ? (
131
+ // Fixed at the control band whatever `trailing` holds: a stage whose
132
+ // row height followed its content would step the marker in and out of
133
+ // alignment down the ladder, and a badge would sit on a shorter row
134
+ // than a date picker two stages up.
135
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: INLINE_CONTROL_HEIGHT }}>
136
+ <View style={{ flex: 1, minWidth: 0 }}>{titleText}</View>
137
+ {trailing}
138
+ </View>
139
+ ) : (
140
+ titleText
141
+ )}
102
142
  {meta ? <Text size="xs" color="muted">{meta}</Text> : null}
103
143
  </View>
104
144
  {children}
@@ -1,13 +1,12 @@
1
- import { useRef, useState } from "react";
1
+ import { useRef, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Button } from "./button";
4
4
  import { DatePicker } from "./date_picker";
5
5
  import { DetailRow, DetailTable } from "./detail_row";
6
6
  import { formatDate } from "./format_date";
7
- import { Divider } from "./divider";
8
7
  import { InlineEditView } from "./inline_edit";
9
8
  import { InlineStatic } from "./inline_static";
10
- import { Popover, PopoverContent } from "./popover";
9
+ import { Popover, PopoverContent, PopoverFooter, PopoverHeader } from "./popover";
11
10
  import { DialogSectionHeadingTitle } from "./section_heading";
12
11
  import { Text } from "./text";
13
12
  import { TextInputField } from "./text_input_field";
@@ -86,6 +85,17 @@ export interface ReferenceFact {
86
85
  * the input.
87
86
  */
88
87
  type?: "text" | "date";
88
+ /**
89
+ * A verb ABOUT this value — an `InlineButton`, per the kit's rule that such a
90
+ * verb travels with what it acts on. "Look this tax id up in the business
91
+ * register", "call this number".
92
+ *
93
+ * Rendered at REST, never inside the draft, and that is a correctness bound
94
+ * rather than a layout choice: an action here acts on the RECORD, while a
95
+ * draft holds values seeded before it ran. Fire one mid-draft and Save would
96
+ * write the pre-action values straight back over whatever it just fetched.
97
+ */
98
+ action?: ReactNode;
89
99
  }
90
100
 
91
101
  export interface ReferenceFieldProps {
@@ -250,16 +260,25 @@ export function ReferenceField(props: ReferenceFieldProps) {
250
260
  (`DialogSectionHeadingTitle`, ####) with the code as its description
251
261
  rather than a hand-picked font weight; the facts are `DetailRow`s, which
252
262
  is what label-beside-value IS everywhere else on this page; and the
253
- destructive verb is fenced off by a `Divider` instead of floating after
263
+ verbs sit in the popover's own pinned footer rather than floating after
254
264
  the last fact. Width matches `Peek`'s own content width so every peek in
255
265
  an app is the same object.
256
266
  `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
257
267
  STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
258
268
  page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
259
269
  the summary side-by-side, which is the whole point of a glance. */}
260
- <PopoverContent style={{ width: 320 }} disableBodyScroll>
261
- <View style={{ gap: 12 }}>
270
+ {/* `PopoverContent` PARTITIONS its children: a `PopoverHeader` sits above
271
+ the scroller, a `PopoverFooter` is pinned below it, and everything else
272
+ scrolls between them. This component used none of that — it disabled
273
+ the body scroll and hand-rolled both bands as ordinary children, which
274
+ is invisible at three facts and fatal at eleven: the panel ran past the
275
+ viewport with no way to scroll, carrying its own Save button off-screen
276
+ with it. The three-fact fixture is what hid it. */}
277
+ <PopoverContent style={{ width: 320, maxHeight: 420 }}>
278
+ <PopoverHeader>
262
279
  <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
280
+ </PopoverHeader>
281
+ <View style={{ gap: 12 }}>
263
282
  {/* ONE geometry for both modes — the table's own 40px band, which is
264
283
  `CONTROL_HEIGHT` and exactly what `TextInputField` renders at. The
265
284
  read row therefore RESERVES the space its editor will need, and
@@ -305,15 +324,21 @@ export function ReferenceField(props: ReferenceFieldProps) {
305
324
  editors, border and all, and reaching for it means the
306
325
  alignment survives the control geometry changing. Copying
307
326
  the box here instead would drift the first time it does. */}
308
- <InlineStatic
309
- /* A date's canonical value is its ISO string — that is what
310
- the picker reads and what Save sends — so the FORMATTING
311
- happens here, where the type is known. Handing the caller
312
- that job would make `value` mean two things (display in
313
- read, ISO in the draft) and the two would drift. */
314
- value={f.type === "date" ? formatDate(f.value, { locale: localeTag }) : f.value}
315
- multiline={f.multiline}
316
- />
327
+ <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
328
+ <View style={{ flex: 1, minWidth: 0 }}>
329
+ <InlineStatic
330
+ /* A date's canonical value is its ISO string — that is
331
+ what the picker reads and what Save sends so the
332
+ FORMATTING happens here, where the type is known.
333
+ Handing the caller that job would make `value` mean
334
+ two things (display in read, ISO in the draft) and the
335
+ two would drift. */
336
+ value={f.type === "date" ? formatDate(f.value, { locale: localeTag }) : f.value}
337
+ multiline={f.multiline}
338
+ />
339
+ </View>
340
+ {f.action}
341
+ </View>
317
342
  </DetailRow>
318
343
  ),
319
344
  )}
@@ -339,15 +364,20 @@ export function ReferenceField(props: ReferenceFieldProps) {
339
364
  edit-only reference would otherwise draw a rule under the facts and
340
365
  fence off an empty band, which is chrome asserting a structure that
341
366
  is not there. */}
342
- <Divider />
343
- {/* THE DRAFT'S FOOTER REPLACES the peek's, it does not join it. Change
344
- and Open are moves AWAY from an unsaved draft — one detaches the
345
- record being edited, one navigates off it so offering either here
346
- would be offering to lose the typing. Cancel and Save are the only
347
- two exits, which is also what the pinned popover promised. */}
367
+ </View>
368
+ {/* `PopoverFooter` PINNED outside the scroller, and it owns the rule,
369
+ the full-bleed inset and the action-layout alignment this component
370
+ used to hand-roll. Hand-rolled, the verbs were a body child: they
371
+ scrolled away with the facts, so a long block hid its own Save.
372
+
373
+ THE DRAFT'S FOOTER REPLACES the peek's, it does not join it. Change
374
+ and Open are moves AWAY from an unsaved draft — one detaches the
375
+ record being edited, one navigates off it — so offering either here
376
+ would be offering to lose the typing. Cancel and Save are the only
377
+ two exits, which is also what the pinned popover promised. */}
378
+ <PopoverFooter align={editing ? "end" : "space-between"}>
348
379
  {editing ? (
349
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
350
- <View style={{ flex: 1 }} />
380
+ <>
351
381
  <Button title={t.cancel} color="secondary" disabled={saving} onPress={closeDraft} />
352
382
  {/* Disabled until something DIFFERS: with nothing to send, a save
353
383
  is a write that fires the record's hooks and bumps its
@@ -358,52 +388,46 @@ export function ReferenceField(props: ReferenceFieldProps) {
358
388
  disabled={!dirty || saving}
359
389
  onPress={() => void save()}
360
390
  />
361
- </View>
391
+ </>
362
392
  ) : (
363
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
364
- {/* LEFT PAIR acts on the LINK — which record this points at. Both
365
- are unconditional: the peek always offers "point it elsewhere"
366
- and "leave it empty", so its footer has ONE shape everywhere
367
- instead of four depending on which callbacks a call site
368
- remembered. */}
369
- <Button
370
- title={t.change}
371
- color="secondary"
372
- accessibilityLabel={`${t.change} ${name}`}
373
- onPress={() => { setPeekOpen(false); onChange(); }}
374
- />
375
- {/* No fill — Clear is the least-reached verb here, and the one whose
376
- result the reader is least likely to want by accident, so it
377
- carries the least weight of the four. */}
378
- <Button
379
- title={t.clear}
380
- accessibilityLabel={`${t.clear} — ${name}`}
381
- onPress={() => { setPeekOpen(false); onClear(); }}
382
- />
383
- {/* The spacer is the SEAM between what the two pairs touch: left the
384
- link, right the record it points at. Without that split, Open's
385
- position is just "pushed over". */}
386
- <View style={{ flex: 1 }} />
387
- {/* RIGHT PAIR acts on the RECORD the link points at — correct its
388
- data, or go to it. Edit takes the ONE filled-dark rung because it
389
- is the only verb here that leads to a commit, and it hands that
390
- rung straight to Save when the draft opens: one primary per mode,
391
- never two. Every other verb stays `secondary` and FILLED a
392
- fill-less Button shows no box, so its ink sits a padding inside
393
- its own edge, and one boxless label in a row of boxes reads as
394
- indented (the geometry that made `TextButton` exist). */}
395
- {editable ? (
396
- <Button title={t.edit} color="primary" accessibilityLabel={`${t.edit} — ${name}`} onPress={openDraft} />
397
- ) : null}
398
- {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
399
- four of these peeks, and four buttons announcing a bare "Open"
400
- are four controls a screen reader cannot tell apart. */}
401
- {onOpen ? (
402
- <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
403
- ) : null}
404
- </View>
393
+ <>
394
+ {/* LEFT acts on the LINK — which record this points at. Both are
395
+ unconditional: the peek always offers "point it elsewhere" and
396
+ "leave it empty", so its footer has ONE shape everywhere. */}
397
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
398
+ <Button
399
+ title={t.change}
400
+ color="secondary"
401
+ accessibilityLabel={`${t.change} — ${name}`}
402
+ onPress={() => { setPeekOpen(false); onChange(); }}
403
+ />
404
+ {/* No fill — the least-reached verb here, and the one whose
405
+ result the reader is least likely to want by accident. */}
406
+ <Button
407
+ title={t.clear}
408
+ accessibilityLabel={`${t.clear} — ${name}`}
409
+ onPress={() => { setPeekOpen(false); onClear(); }}
410
+ />
411
+ </View>
412
+ {/* RIGHT acts on the RECORD the link points at. Edit takes the ONE
413
+ filled-dark rung because it is the only verb here that leads to
414
+ a commit, and it hands that rung straight to Save when the
415
+ draft opens: one primary per mode, never two. */}
416
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
417
+ {editable ? (
418
+ <Button title={t.edit} color="primary" accessibilityLabel={`${t.edit} ${name}`} onPress={openDraft} />
419
+ ) : null}
420
+ {/* `openLabel` names the DESTINATION ("Open customer"): a page
421
+ carries four of these peeks, and four buttons announcing a
422
+ bare "Open" are four controls a screen reader cannot tell
423
+ apart. */}
424
+ {onOpen ? (
425
+ <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
426
+ ) : null}
427
+ </View>
428
+ </>
405
429
  )}
406
- </View>
430
+ </PopoverFooter>
407
431
  </PopoverContent>
408
432
  </Popover>
409
433
  );
package/src/stepper.tsx CHANGED
@@ -15,6 +15,7 @@ import { Icon } from "./icon";
15
15
  import { Text } from "./text";
16
16
  import { PressableHighlight } from "./pressable_highlight";
17
17
  import { AnimationFadeIn } from "./animation_fade_in";
18
+ import { NODE, STEP_HEAD_TEXT_LINE, STEP_ROW_PAD, markerTopOffset } from "./stepper_layout";
18
19
 
19
20
  // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
20
21
  // where we are (ring + white centre, pulses when live); `done` = passed (filled);
@@ -64,6 +65,15 @@ export interface StepProps extends StepPositional {
64
65
  * `active` washes the selected one (a panel can also sit beside it in vertical). */
65
66
  onPress?: () => void;
66
67
  active?: boolean;
68
+ /**
69
+ * VERTICAL only. Height of the content's FIRST ROW — the marker centres on it.
70
+ * Defaults to one line of `sm` text, which is what a step's first row is unless
71
+ * you put something taller on it. Pass `INLINE_CONTROL_HEIGHT` when the row
72
+ * carries an inline editor beside the label, or the marker centres on the text
73
+ * while the label centres in the taller row and reads low by half the
74
+ * difference. Ignored horizontally, where the marker sits above the label.
75
+ */
76
+ headHeight?: number;
67
77
  accessibilityLabel?: string;
68
78
  }
69
79
 
@@ -123,7 +133,7 @@ export function Stepper(props: StepperProps) {
123
133
  }
124
134
 
125
135
  export function Step(props: StepProps) {
126
- const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
136
+ const { status, children, onPress, active, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
127
137
  const { orientation, color, live } = useContext(StepperContext);
128
138
  // Each step is one `listitem`, and the live one says so with `aria-current`.
129
139
  // It goes on the ITEM, not the label: "current" is a fact about this position
@@ -173,7 +183,7 @@ export function Step(props: StepProps) {
173
183
  <View {...item}>
174
184
  <AnimationFadeIn translateY={6}>
175
185
  <View style={styles.vItem}>
176
- <View style={styles.vSpineCol}>
186
+ <View style={[styles.vSpineCol, { paddingTop: markerTopOffset(headHeight) }]}>
177
187
  <Marker status={status} color={color} live={live} />
178
188
  {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
179
189
  </View>
@@ -244,8 +254,6 @@ function Pulse({ color }: { color: string }) {
244
254
  return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
245
255
  }
246
256
 
247
- const NODE = 18;
248
-
249
257
  const styles = StyleSheet.create({
250
258
  hRow: { flexDirection: "row" },
251
259
  hStep: { flex: 1, alignItems: "center", gap: 8 },
@@ -257,12 +265,12 @@ const styles = StyleSheet.create({
257
265
  hLabel: { alignItems: "center" },
258
266
 
259
267
  vItem: { flexDirection: "row", gap: 12 },
260
- vSpineCol: { width: NODE, alignItems: "center", paddingTop: 3 },
268
+ vSpineCol: { width: NODE, alignItems: "center" },
261
269
  vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
262
270
  vContent: { flex: 1 },
263
271
  vGap: { paddingBottom: 12 },
264
272
  vPress: { borderRadius: 8, marginHorizontal: -10 },
265
- vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 2 },
273
+ vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: STEP_ROW_PAD },
266
274
  vActive: { backgroundColor: colors.zinc[100] },
267
275
 
268
276
  discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Where a vertical `Step`'s marker sits against its content.
3
+ *
4
+ * The marker centres on the content's FIRST ROW — not on the content box, which
5
+ * can be many lines tall. That row is usually a line of text, but a dense stage
6
+ * puts a control on it (a date beside the milestone's name), and a control is
7
+ * twice a text line's height. Centring on the wrong one is a visible drift on
8
+ * every row of the ladder, so the offset is derived from the row's height rather
9
+ * than tuned to whatever the first caller happened to render.
10
+ *
11
+ * RN-free so the arithmetic is testable: `stepper.tsx` imports `react-native`,
12
+ * which Vitest cannot parse.
13
+ */
14
+
15
+ /** The marker disc's diameter. */
16
+ export const NODE = 18;
17
+
18
+ /** `vRowBox`'s vertical padding — the gap above the content's first row. */
19
+ export const STEP_ROW_PAD = 2;
20
+
21
+ /**
22
+ * A single line of `Text size="sm"` on web (`text.css`), which is what a step's
23
+ * first row is unless it carries a control. NOT the native StyleSheet's 24: the
24
+ * kit renders on web, and the two scales disagree.
25
+ */
26
+ export const STEP_HEAD_TEXT_LINE = 20;
27
+
28
+ /**
29
+ * Top padding for the spine column so the marker's centre lands on the centre of
30
+ * a first row `headHeight` tall.
31
+ *
32
+ * Never negative: a row SHORTER than the marker would otherwise lift the disc
33
+ * above the content box and out of the step's own bounds, clipping it against
34
+ * whatever sits above.
35
+ */
36
+ export function markerTopOffset(headHeight: number): number {
37
+ return Math.max(0, STEP_ROW_PAD + headHeight / 2 - NODE / 2);
38
+ }