@lotics/ui 25.1.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1175,6 +1190,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1175
1190
  moving, one stage live, different controls per stage; `task` is N identical rows ticked in any
1176
1191
  order. `PipelineNote` is a condition ON a stage (never a `Callout` above the run);
1177
1192
  `PipelineField` stays editable on a PASSED stage, which is what makes a mis-entry fixable.
1193
+ A field stacks UNDER the title (two lines per stage); on a ladder long enough that this
1194
+ pushes the run past a screenful, **`PipelineStage.trailing`** puts ONE value on the title's
1195
+ own row instead. Never hand-roll that row: a control is twice a text line's height, so the
1196
+ marker — which centres on the first row — reads half the difference too high against it.
1197
+ `trailing` fixes the row at `INLINE_CONTROL_HEIGHT` and tells the `Step`, so the ladder keeps
1198
+ one rhythm whatever the value is. An ACT still belongs in `PipelineActions`.
1178
1199
  Worked screen: `examples/tpl_record.tsx` § Progress — the desks (Sales → Operations →
1179
1200
  Accounting) ARE the stages, each owning the facts it stamps and, on the live one, the act
1180
1201
  that leaves it. A `PipelineStage` is deliberately NOT pressable: its body holds the controls.
@@ -1185,6 +1206,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1185
1206
  switcher; the guided-run / agent-feed primitive. It is a **`list` of `listitem`s** with
1186
1207
  `aria-current="step"` on the live one — a sequence of NAMED positions whose steps carry
1187
1208
  their own content is a list, never a `progressbar`.
1209
+ VERTICAL: the marker centres on the content's FIRST ROW, assumed to be one line of `sm` text.
1210
+ Put anything taller on that row — an inline editor beside the label — and pass
1211
+ **`Step.headHeight`** (`INLINE_CONTROL_HEIGHT` for an editor) or the marker stays pinned to
1212
+ the text line while the label centres in the taller row. Prefer `PipelineStage.trailing`,
1213
+ which does this for you.
1188
1214
 
1189
1215
  ### Files
1190
1216
 
@@ -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.0.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}
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
+ }