@lotics/ui 44.8.0 → 44.9.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
@@ -16,8 +16,8 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
- | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
19
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
21
  | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
@@ -72,7 +72,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
72
72
  a press it refuses. Rows tick ONE at a time; derive position from the highest stamp and one click marks
73
73
  everything below it without inventing dates. A checklist REPORTS — a field owned by a section is
74
74
  named as a gap with a `ChecklistNote action` that jumps there, never edited or previewed in the
75
- row. Worked in `examples/tpl_record.tsx` § Progress. See `docs/catalog.md`.
75
+ row. A per-ROW third axis, `ChecklistItem.verdict` (`"pass"`/`"fail"`), is a DECIDED outcome — a
76
+ QC gate, a reconciliation match — never toggled; see `docs/catalog.md` for why it withholds
77
+ `onToggle`. Worked in `examples/tpl_record.tsx` § Progress. See `docs/catalog.md`.
76
78
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
77
79
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
78
80
  never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
@@ -318,6 +318,16 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
318
318
  operator opening a job of twelve fee lines to see where a dropped invoice landed should find
319
319
  the one filled shape in a column of blanks without reading anything or separating two hues.
320
320
  The hole keeps the column's width and announces nothing.
321
+
322
+ **Derive the kind from the same before/after the row's `DiffValue` carries — never from a
323
+ boolean "did this differ".** A field with no prior value (`before` empty or absent) that a run
324
+ now proposes a value for is an ADD, not a CHANGE, and a local review row that only asks
325
+ "does `draft !== before`" answers yes for both and always renders `changed` — the amber pencil
326
+ on a value that was never there to begin with, which reads as the AI having second-guessed a
327
+ figure it actually just filled in for the first time. This bites hardest on the record that
328
+ looks most ordinary: the first proposal any fresh record ever gets has an empty `before` on
329
+ every field, so every mark on that one run is wrong until the kind is derived per field
330
+ (`before === after` → no mark; `before === ""` → `added`; otherwise → `changed`).
321
331
  - **`useChangeSet(ids, { initial })`** (`@lotics/ui/use_change_set`) — the bookkeeping:
322
332
  `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`, `accepted`/`rejected`/`pending`,
323
333
  `keptCount`/`total`/`settled`. `initial` defaults to **`accepted`** so an operator drops
@@ -387,6 +397,16 @@ shape its root draws, and every surface needing a diff somewhere else rebuilds i
387
397
  only reject costs the whole re-entry.
388
398
  - **Gate the commit on what will actually be written**, not on the proposal count — disable at
389
399
  zero kept.
400
+ - **A decided row's Keep/Drop must be reachable from BOTH directions when `initial: "accepted"`.**
401
+ `undo` returns a row to its DEFAULT, not to `"pending"` — so on the common default
402
+ (`initial: "accepted"`, the operator drops the exceptions rather than re-approving every
403
+ identical field) a row starts already decided and Undo is a no-op with nothing to reach:
404
+ there is no path back to a `pending`-style Keep/Drop pair, and the row is stuck accepted
405
+ forever. The collapsed-to-Undo anatomy (value + one "Undo" button, swapping to Keep/Drop only
406
+ while `pending`) is correct ONLY under `initial: "pending"`, where every row genuinely starts
407
+ undecided. Under `initial: "accepted"`, render Keep/Drop as an always-visible TOGGLE instead —
408
+ both buttons present, the one matching the row's current status disabled, the other live —
409
+ so either direction is one press away regardless of where the row started.
390
410
  - **The mark rides the LABEL, and the value sits in an `InlineSlot`.** Both are alignment
391
411
  before they are semantics. A mark beside the VALUE indents every row it marks by its own
392
412
  width plus a gap, so an unmarked row needs a spacer of exactly the glyph's width to keep up —
package/docs/catalog.md CHANGED
@@ -179,6 +179,17 @@ axes carry every shape it takes, and BOTH are decisions about meaning, not looks
179
179
 
180
180
  A record's Progress and a desk's to-do list are the same component two booleans apart.
181
181
 
182
+ **A row can carry a DECIDED outcome instead of a tick — `ChecklistItem.verdict` (`"pass"` /
183
+ `"fail"`).** This is a per-ROW axis, not a list-level one: a QC gate, a reconciliation match,
184
+ any row whose mark means an inspection SETTLED rather than work getting done. `fail` renders in
185
+ red with an X (`StepStatus`'s `fail`, shared with `Stepper`/`AgentRun` via the same `Marker`) —
186
+ distinct from `warning`, which means "still open, needs a look," where `fail` means the answer is
187
+ already no. A verdict is reported, not toggled: passing `onToggle` alongside `verdict` is a
188
+ no-op (withheld internally), because neither of the toggle-capable marks (`CheckboxInput`,
189
+ `CheckCircle`) has a fail-shaped state — a caller with an inspector who taps to CONFIRM a
190
+ pass/fail should drive that from its own control, then set `verdict` from the result, never
191
+ reach for `done`+`onToggle` on the same row a verdict already settled.
192
+
182
193
  **A row carries its TITLE and its own stamp, and nothing else.** A checklist REPORTS: it does not
183
194
  collect, and it does not PREVIEW. A field some section owns — an assignee, an address, a portal
184
195
  login — is NAMED as a gap by a `ChecklistNote` whose `action` jumps to that section, never edited
@@ -1266,6 +1277,30 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1266
1277
  trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
1267
1278
  Charts page instead — a delivery order has no base to compute from, and inventing one to
1268
1279
  place a component is how invented needs start.
1280
+ - **`running_ledger`** — `RunningLedger`: a CHRONOLOGICAL, self-totalling ledger — a stock
1281
+ kardex, a statement of account — where every row carries the balance AS OF that row. Not
1282
+ `Ledger`: that compound closes a fixed set of charge/receipt groups into one total and has no
1283
+ chronological concept at all. `opening` (label + optional date + the anchor balance) then
1284
+ `rows` (date, label, a SIGNED `amount` — positive in/debit, negative out/credit, one field
1285
+ rather than paired columns so the same shape serves a stock quantity and a money statement),
1286
+ each rendered with its running balance **computed by the component**, never accepted as a
1287
+ caller-supplied number — a running balance is exactly the figure that must equal what it
1288
+ accumulates, and a caller-supplied one is the one kind of drift no reader can catch by looking.
1289
+ `format` is ONE formatter for the whole ledger (`formatMoney`, or a quantity formatter with a
1290
+ unit suffix) — every row shares one unit, which is what lets the column state it once.
1291
+ `criticalAtOrBelow` reads a row's balance in danger tone once it reaches a caller-declared
1292
+ floor (a stock-out, a negative balance) — **the ONLY tone this component owns**, evaluated
1293
+ independently on EVERY balance in the column: each row, the opening anchor (so an already-critical
1294
+ opening reads danger even with zero rows following it), and the closing line. A row's `amount`
1295
+ is never auto-toned by its arithmetic sign, because an OUT movement is routine business as often
1296
+ as an IN one and colouring every negative red claims a meaning the number alone does not carry. Closes with a `Divider`-set current-balance line at `Ledger`'s own
1297
+ `LedgerTotal` weight, `closingLabel` overriding the locale default ("Current balance") for a
1298
+ domain the pack cannot own generically ("Tồn kho hiện tại"). No `examples/` worked example yet
1299
+ — a stock kardex or an AR statement of account is a genuinely different shape from every
1300
+ existing template (the 3-statement accrual grammar in `tpl_statements`, the fleet-register
1301
+ drawer in `tpl_stock`), so forcing it into either would be a mismatch rather than a worked
1302
+ example; proven against real, live deployed consumers instead (a warehouse and a receivables
1303
+ app) until a template of its own earns its place.
1269
1304
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
1270
1305
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
1271
1306
  like the editors above and below it), whose press opens a PEEK of that record's facts.
@@ -1329,6 +1364,22 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1329
1364
  reference is a FIELD, not a section — one pointer to one other row never earns a heading
1330
1365
  and a rail entry. Worked example: [`tpl_record`](../examples/tpl_record.tsx) § General
1331
1366
  (customer) and its Transport parties.
1367
+ - **`related_record_row`** — `RelatedRecordRow`: a pointer to a record in a DIFFERENT app's
1368
+ table — never `ReferenceField`, which peeks an in-WORKSPACE link this app's own table holds
1369
+ (a facts draft, Change/Clear). A suite of apps that deploys one bundle per department has no
1370
+ in-app route to a sibling's record and no local fields to peek, so this only ever reports:
1371
+ `kind` (an `Eyebrow` naming what the reference IS — "Lệnh sản xuất"), `code` (the record's own
1372
+ human id), an optional `status` (pass a `Badge`), and `onOpen` — the HOST's job, typically
1373
+ `openExternal(url)` from `@lotics/app-sdk` into the sibling app's own deployed URL, since only
1374
+ the calling app's author knows that app's subdomain and its route shape for a record page.
1375
+ Omitting `onOpen` renders a plain, non-interactive reference — never a press target wired to
1376
+ nothing, which is worse than no affordance. Composes as a `DetailRow`'s value for a single
1377
+ relationship, or repeats plainly (no per-row label) in a list for several — the surrounding
1378
+ label or heading is the caller's, this renders one reference. No `examples/` worked example
1379
+ yet, for the same reason as `running_ledger` above: what this component is FOR — a reference
1380
+ into a genuinely separate deployed app — has no natural home inside any single-app template in
1381
+ this repo without inventing a fictional sibling app to point at; proven against real, live
1382
+ deployed consumers instead.
1332
1383
  - **`peek`** — `Peek`: drill-down for a reference — press a name/id where it appears and get its
1333
1384
  details in an anchored popover, without leaving the screen; keep the content a summary with ONE
1334
1385
  action to the full record. **`variant` picks the register the TRIGGER sits in, and getting it
@@ -1368,6 +1419,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1368
1419
  On pressable rows dropped values stay one tap away — the row opens the record; a READ-ONLY
1369
1420
  register (rows without `onPress`) has no door, so give the columns it can't afford to lose
1370
1421
  a low `priority` (e.g. `priority: 1` — outlives its right-side neighbours).
1422
+ **Set `priority` from what the page already told the reader matters, never leave it at the
1423
+ default.** A `SummaryLine`/`KPIStrip` figure above the register is a claim that ONE column is
1424
+ the reason to scan this page; left unset, `priority` defaults to column order and sheds
1425
+ right-to-left, so that exact column is the first thing table_fit drops the moment the
1426
+ container narrows — the banner keeps promising a number the row beneath it can no longer
1427
+ show. The fix is one line per column, not a rewrite: rank by what the summary already flagged
1428
+ as the point, not by where the column happens to sit in the header row.
1371
1429
  **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.** A
1372
1430
  register answers "which one"; the moment it cannot answer "and what about it" the reader is
1373
1431
  sent to a drawer, loses the surrounding rows, and pays a navigation to come back — which is
@@ -1658,7 +1716,13 @@ component rather than showing it at zero.
1658
1716
  unsorted and out-of-bounds ranges all normalize; no input drops a character.
1659
1717
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1660
1718
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
1661
- (`RemainderMeterLabels` localized via the provider).
1719
+ (`RemainderMeterLabels` localized via the provider). **A THREE-way comparison (ordered vs.
1720
+ received vs. invoiced, say) is TWO stacked meters against the same `total`** — one per pairwise
1721
+ relationship (ordered-vs-received, ordered-vs-invoiced) — not a new component: the primitive's
1722
+ whole model is one source distributed one way, and a caller with a second relationship to show
1723
+ reaches for a second instance before reaching for a wider one. Extract a true 3-column strip
1724
+ only once a SECOND real caller needs the exact same 3-way shape (the kit's own
1725
+ extract-on-the-second-hand-roll rule) — one confirmed need is not yet a pattern.
1662
1726
  - **`allocation_row`** — `AllocationRow`: one target's row in an allocation surface (pairs
1663
1727
  with `RemainderMeter`).
1664
1728
 
@@ -115,7 +115,15 @@ restyle a heading level per-page.
115
115
  (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
116
116
  at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
117
117
  heading row's right edge (the title's `flex: 1` pushes them) — see the add-placement law
118
- below. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
118
+ below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
119
+ drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
120
+ up whatever is left, taking the `description` down with it. A label that repeats the heading
121
+ verbatim is the usual way this gets long enough to bite: measured on a real section, an
122
+ 11-character title-plus-button pair left the title a comfortable column, and the SAME text
123
+ duplicated onto the button squeezed both title and description into a 107px ribbon wrapping
124
+ one or two words per line for eleven lines total. The button-label law already asks for
125
+ 2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
126
+ not just a wordier button. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
119
127
  paged, or long enough that the reader would have to scan to know. Over a list or thread
120
128
  rendered whole directly beneath its own heading it restates what is already in view, and the
121
129
  heading reads busier for it. The page column is a **`SectionStack`** — it owns the
package/docs/reviewing.md CHANGED
@@ -308,6 +308,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
308
308
  no option colours). The fix is often config or a write path, not CSS.
309
309
  - **The obvious column may be the empty one.** Reading only the field the composer writes ships a
310
310
  feature empty on every row and correct in code review.
311
+ - **List every field a write path SETS and check each one has a render on the record's own read
312
+ surface.** A screen with no visual defect can still be hiding real stored data: a workflow or
313
+ form commits a field, the record page never grew a row for it, and nothing about the page
314
+ LOOKS wrong — there is no empty box, no broken layout, just an absence with nothing marking
315
+ it. Pull the field list from the write path (a workflow's `set`, a mutation's payload), not
316
+ from the screen, then confirm each one against the record's own DetailRow/Text tree; where one
317
+ is missing, query the record directly to prove the value is really sitting there unseen before
318
+ calling it a fix. A screenshot cannot catch this — it only shows what already renders.
311
319
  - **A fixture can fail in the generous direction too**: an image tile pointed at a dead URL renders
312
320
  nothing, so the chip designed to survive a photo was never tested against one.
313
321
  - **Grep rendered text for internal identifiers — first probe, every audit** —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.8.0",
3
+ "version": "44.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -57,6 +57,7 @@
57
57
  "./use_selection": "./src/use_selection.ts",
58
58
  "./use_section_nav": "./src/use_section_nav.ts",
59
59
  "./ledger": "./src/ledger.tsx",
60
+ "./running_ledger": "./src/running_ledger.tsx",
60
61
  "./use_selection_mode": "./src/use_selection_mode.ts",
61
62
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
62
63
  "./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
@@ -208,6 +209,7 @@
208
209
  "./counter": "./src/counter.tsx",
209
210
  "./link": "./src/link.tsx",
210
211
  "./reference_field": "./src/reference_field.tsx",
212
+ "./related_record_row": "./src/related_record_row.tsx",
211
213
  "./text_disclosure": "./src/text_disclosure.tsx",
212
214
  "./text_link": "./src/text_link.tsx",
213
215
  "./sort_header": "./src/sort_header.tsx",
package/src/checklist.tsx CHANGED
@@ -209,6 +209,15 @@ export interface ChecklistItemProps extends StepPositional {
209
209
  accessibilityLabel?: string;
210
210
  /** Ticked. */
211
211
  done?: boolean;
212
+ /**
213
+ * A DECIDED outcome, not a completion tick — a QC pass/fail, a match that
214
+ * held or didn't. Takes over the row's mark from `done`/`current` (a
215
+ * verdict is reached by definition) and renders `fail` in red with an X, so
216
+ * a scan of the run tells "settled" from "settled, and the answer is no" —
217
+ * ticking every row and separately noting a rejection in prose is the
218
+ * failure this exists to replace.
219
+ */
220
+ verdict?: "pass" | "fail";
212
221
  /** The row the reader is waiting on — medium weight, so a run full of ticks
213
222
  * still says which one is owed. */
214
223
  current?: boolean;
@@ -241,6 +250,7 @@ export function ChecklistItem({
241
250
  title,
242
251
  accessibilityLabel,
243
252
  done,
253
+ verdict,
244
254
  current,
245
255
  onToggle,
246
256
  trailing,
@@ -249,10 +259,18 @@ export function ChecklistItem({
249
259
  children,
250
260
  ...positional
251
261
  }: ChecklistItemProps) {
262
+ const settled = done || verdict != null;
252
263
  return (
253
264
  <Step
254
- status={done ? "done" : current ? "current" : "upcoming"}
255
- onToggle={onToggle}
265
+ status={verdict === "fail" ? "fail" : verdict === "pass" ? "done" : done ? "done" : current ? "current" : "upcoming"}
266
+ // A verdict is DECIDED elsewhere (an inspection, an AI check) and
267
+ // reported here, not toggled — passing `onToggle` through would put a
268
+ // completion checkbox on a row whose mark already means something else,
269
+ // and neither `CheckCircle` nor `CheckboxInput` (the interactive marks)
270
+ // know a `fail` state exists, so it would silently render as "not done"
271
+ // instead. The non-interactive `Marker` does, so this keeps a verdict
272
+ // row on that path.
273
+ onToggle={verdict != null ? undefined : onToggle}
256
274
  accessibilityLabel={accessibilityLabel ?? title}
257
275
  {...positional}
258
276
  >
@@ -262,7 +280,9 @@ export function ChecklistItem({
262
280
  WRAPS, so a long title drops its stamp to the next line rather than
263
281
  crushing it. */}
264
282
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
265
- <Text size="sm" weight={current ? "medium" : "regular"} color={done ? "muted" : "default"}>
283
+ {/* A fail stays in default ink, never muted muting reads as
284
+ "settled, move on" and a rejection is the opposite of that. */}
285
+ <Text size="sm" weight={current ? "medium" : "regular"} color={verdict === "fail" ? "danger" : settled ? "muted" : "default"}>
266
286
  {title}
267
287
  </Text>
268
288
  {trailing}
package/src/ledger.tsx CHANGED
@@ -44,8 +44,11 @@ function useLedger(): LedgerContextValue {
44
44
  }
45
45
 
46
46
  /** Signed display: negatives render as "− <abs>" (a receipt against charges).
47
- * `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0. */
48
- function signed(format: (n: number) => string, n: number) {
47
+ * `n + 0` normalizes -0 (a zero receipts sum passed negated) to plain 0.
48
+ * Exported for `RunningLedger`, which shares this exact glyph rule for every
49
+ * money/quantity figure it renders — one definition, so the two components'
50
+ * output can never drift into two different negative-number conventions. */
51
+ export function signed(format: (n: number) => string, n: number) {
49
52
  return n < 0 ? `− ${format(-n)}` : format(n + 0);
50
53
  }
51
54
 
package/src/locale.tsx CHANGED
@@ -74,6 +74,9 @@ export interface LoticsLocale {
74
74
  clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
75
75
  /** `Ledger`: the screen-reader name of a peekable row. */
76
76
  ledger: { rowDetails: (label: string) => string };
77
+ /** `RunningLedger`: the closing line's label ("Số dư hiện tại", "Tồn kho
78
+ * hiện tại") — the ledger's one derived, always-current figure. */
79
+ runningLedger: { currentBalance: string };
77
80
  /** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
78
81
  * label of its own. A pipeline stage always passes its title, so this is the
79
82
  * bare-`Step` fallback. */
@@ -207,6 +210,10 @@ export interface LoticsLocale {
207
210
  * a SOURCE with different words. */
208
211
  fileRow: { open: (name: string) => string };
209
212
  sources: { open: (label: string) => string; heading: string };
213
+ /** `RelatedRecordRow`: the accessible name for its press target, when the
214
+ * caller has not overridden it — "<kind> <code>", e.g. "Kế hoạch sản xuất
215
+ * KH-2026-0001". */
216
+ relatedRecordRow: { open: (kind: string, code: string) => string };
210
217
  /** `Counter`: the two steppers, named for the value they move. */
211
218
  counter: { decrease: (label: string) => string; increase: (label: string) => string };
212
219
  /** `AllocationRow`: the a11y name of the amount input (naming its destination),
@@ -303,6 +310,7 @@ export const en: LoticsLocale = {
303
310
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
304
311
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
305
312
  ledger: { rowDetails: (label) => `${label} details` },
313
+ runningLedger: { currentBalance: "Current balance" },
306
314
  stepper: { complete: "Complete step", progress: "Progress" },
307
315
  stepProgress: {
308
316
  complete: (total) => `Complete (${total}/${total})`,
@@ -409,6 +417,7 @@ export const en: LoticsLocale = {
409
417
  infoPopover: { more: "More information", about: (label) => `About ${label}` },
410
418
  fileRow: { open: (name) => `Open ${name}` },
411
419
  sources: { open: (label) => `Open ${label}`, heading: "Sources" },
420
+ relatedRecordRow: { open: (kind, code) => `Open ${kind} ${code}` },
412
421
  counter: { decrease: (label) => `Decrease ${label}`, increase: (label) => `Increase ${label}` },
413
422
  allocationRow: {
414
423
  allocateTo: (label) => `Allocate to ${label}`,
@@ -488,6 +497,7 @@ export const vi: LoticsLocale = {
488
497
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
489
498
  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" },
490
499
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
500
+ runningLedger: { currentBalance: "Số dư hiện tại" },
491
501
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
492
502
  stepProgress: {
493
503
  complete: (total) => `Hoàn tất (${total}/${total})`,
@@ -594,6 +604,7 @@ export const vi: LoticsLocale = {
594
604
  infoPopover: { more: "Thông tin thêm", about: (label) => `Thông tin về ${label}` },
595
605
  fileRow: { open: (name) => `Mở ${name}` },
596
606
  sources: { open: (label) => `Mở ${label}`, heading: "Nguồn" },
607
+ relatedRecordRow: { open: (kind, code) => `Mở ${kind} ${code}` },
597
608
  counter: { decrease: (label) => `Giảm ${label}`, increase: (label) => `Tăng ${label}` },
598
609
  allocationRow: {
599
610
  allocateTo: (label) => `Phân bổ vào ${label}`,
@@ -0,0 +1,80 @@
1
+ import { type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Eyebrow } from "./eyebrow";
5
+ import { Icon } from "./icon";
6
+ import { colors } from "./colors";
7
+ import { PressableHighlight } from "./pressable_highlight";
8
+ import { useLoticsLocale } from "./locale";
9
+
10
+ export interface RelatedRecordRowProps {
11
+ /** What KIND of record this is — the table/app it lives in ("Kế hoạch sản
12
+ * xuất", "Lệnh sản xuất"). Rendered as an `Eyebrow` naming the value below
13
+ * it, the same job an `Eyebrow` does in a cell or a metric caption. */
14
+ kind: string;
15
+ /** The record's own human code ("KH-2026-0001"). */
16
+ code: string;
17
+ /** The record's status, rendered as-is beside the code — pass a `Badge`.
18
+ * Omit for a reference with no lifecycle state worth carrying here. */
19
+ status?: ReactNode;
20
+ /**
21
+ * Opens the record. THE HOST performs the navigation — typically
22
+ * `openExternal(url)` from `@lotics/app-sdk` into a sibling app's own
23
+ * deployed URL, since a suite of apps like this one is several SEPARATELY
24
+ * DEPLOYED bundles with no shared router; only the calling app knows the
25
+ * target app's subdomain and its own route shape for a record page. Omit
26
+ * for a report-only reference with nowhere to send the reader (a linked
27
+ * record whose app cannot be reached, or a same-app case better served by
28
+ * an in-app navigate instead of this component at all).
29
+ */
30
+ onOpen?: () => void;
31
+ /** Accessible name for the row when `onOpen` is set. Defaults to
32
+ * "<kind> <code>" — override when that reads ambiguously (the same kind
33
+ * repeated down a list of several). */
34
+ accessibilityLabel?: string;
35
+ }
36
+
37
+ /**
38
+ * A pointer to ONE record living in a DIFFERENT app's table — never a route
39
+ * inside this bundle, because a document-desk suite deploys one app per
40
+ * department with no in-app path to a sibling's record. Composes as a
41
+ * `DetailRow`'s value for a single relationship ("Kế hoạch sản xuất" →
42
+ * one linked plan) or repeats plainly in a list for several (an order's
43
+ * three downstream production plans) — the surrounding label/heading is the
44
+ * caller's choice, this renders one reference.
45
+ *
46
+ * NOT `ReferenceField`: that component edits an in-workspace link (peek,
47
+ * Change, Clear, an editable facts draft) for a reference the CURRENT app's
48
+ * own table holds. This one only ever reports — the target record's fields
49
+ * live in a table another app's bundle renders, so there is no draft to
50
+ * open and no picker to reassign from here.
51
+ */
52
+ export function RelatedRecordRow(props: RelatedRecordRowProps) {
53
+ const { kind, code, status, onOpen, accessibilityLabel } = props;
54
+ const locale = useLoticsLocale();
55
+ const content = (
56
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
57
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
58
+ <Eyebrow numberOfLines={1}>{kind}</Eyebrow>
59
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
60
+ <Text size="sm" numberOfLines={1}>
61
+ {code}
62
+ </Text>
63
+ {status}
64
+ </View>
65
+ </View>
66
+ {onOpen ? <Icon name="external-link" size={14} color={colors.zinc[400]} /> : null}
67
+ </View>
68
+ );
69
+ if (!onOpen) return content;
70
+ return (
71
+ <PressableHighlight
72
+ focusRing
73
+ onPress={onOpen}
74
+ accessibilityLabel={accessibilityLabel ?? locale.relatedRecordRow.open(kind, code)}
75
+ style={{ paddingVertical: 4 }}
76
+ >
77
+ {content}
78
+ </PressableHighlight>
79
+ );
80
+ }
@@ -0,0 +1,196 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Divider } from "./divider";
3
+ import { Link } from "./link";
4
+ import { signed } from "./ledger";
5
+ import { Text } from "./text";
6
+ import { formatDate } from "./format_date";
7
+ import { useLoticsLocale } from "./locale";
8
+
9
+ export interface RunningLedgerOpening {
10
+ /** What the anchor IS ("Tồn đầu kỳ", "Số dư đầu kỳ"). */
11
+ label: string;
12
+ /** ISO date, when the anchor has one — a stock count date, a statement's
13
+ * period start. Omit for a balance with no dated origin. */
14
+ date?: string;
15
+ /** The balance BEFORE the first row. */
16
+ balance: number;
17
+ }
18
+
19
+ export interface RunningLedgerRow {
20
+ id: string;
21
+ /** ISO date. Rows render in the order given — this component does not sort
22
+ * them, so the caller's own query order IS the chronology on screen. */
23
+ date: string;
24
+ /** What happened ("Nhập kho theo PO-2026-0001", "Thu tiền hóa đơn HD-014"). */
25
+ label: string;
26
+ /** The movement. Signed: positive is IN/debit, negative is OUT/credit — ONE
27
+ * field rather than paired in/out columns, so the same row shape serves a
28
+ * stock quantity and a money statement without either caller picking the
29
+ * other's vocabulary. */
30
+ amount: number;
31
+ /** A trailing reference link (the source PO, the invoice this receipt was
32
+ * matched against). */
33
+ reference?: { label: string; onPress: () => void };
34
+ }
35
+
36
+ export interface RunningLedgerProps {
37
+ opening: RunningLedgerOpening;
38
+ /** Chronological rows — oldest first. Each row's running balance is this
39
+ * component's own DERIVED value (`opening.balance` plus every `amount` up
40
+ * to and including that row), never a value the caller supplies: a running
41
+ * balance is exactly the row that MUST equal what it accumulates, and a
42
+ * caller-supplied figure is the one shape of drift no reader can catch by
43
+ * looking. */
44
+ rows: RunningLedgerRow[];
45
+ /** Formats every figure on the balance/amount column — `formatMoney` for a
46
+ * statement, or a quantity formatter (`(n) => \`${n.toLocaleString()} kg\`
47
+ * `) for a stock ledger. ONE formatter for the whole ledger: every row here
48
+ * shares one unit, which is what lets the column state it once instead of
49
+ * per row. */
50
+ format: (n: number) => string;
51
+ /** A row whose running balance lands AT OR BELOW this reads in danger tone
52
+ * — a stock-out, an account gone negative. Omit for a ledger with no
53
+ * critical floor (most money statements; a stock ledger typically passes
54
+ * 0). */
55
+ criticalAtOrBelow?: number;
56
+ /**
57
+ * The closing line's label. Default resolves from the locale pack
58
+ * ("Current balance"), which fits a money statement but not every domain
59
+ * this serves both of on day one — a stock kardex wants "Tồn kho hiện
60
+ * tại", specific vocabulary the locale pack cannot own for every future
61
+ * caller. Override per instance the same way `Sources.label` does.
62
+ */
63
+ closingLabel?: string;
64
+ }
65
+
66
+ /**
67
+ * A chronological, self-totalling ledger — a stock kardex, a statement of
68
+ * account — where every row carries the balance AS OF that row. NOT `Ledger`:
69
+ * that component closes a fixed set of charge/receipt GROUPS into one total
70
+ * and has no chronological concept at all; reaching for it here would mean
71
+ * inventing a "group" for what is actually a time series and losing the
72
+ * running balance a kardex or a statement is FOR. Composes a `Divider`-closed
73
+ * current-balance line the same way `Ledger`'s own `LedgerTotal` does, so a
74
+ * page mixing this with a `Ledger` elsewhere still reads as one design
75
+ * language.
76
+ *
77
+ * The balance column is DERIVED — see `rows`' own doc. A platform whose
78
+ * rollup engine cannot produce an ordered cumulative aggregate (no per-row
79
+ * stored balance exists for a stock movement today) is exactly the case this
80
+ * is for: opening balance + a plain list of signed deltas needs no schema
81
+ * change on either side to render correctly.
82
+ */
83
+ export function RunningLedger(props: RunningLedgerProps) {
84
+ const { opening, rows, format, criticalAtOrBelow, closingLabel } = props;
85
+ const locale = useLoticsLocale();
86
+ let running = opening.balance;
87
+ const computed = rows.map((r) => {
88
+ running += r.amount;
89
+ return { row: r, balance: running };
90
+ });
91
+ const closing = computed.length > 0 ? computed[computed.length - 1].balance : opening.balance;
92
+ const closingCritical = criticalAtOrBelow != null && closing <= criticalAtOrBelow;
93
+ // Evaluated independently of `closingCritical` (and of whether there are
94
+ // any rows at all) — an opening balance that already sits at/below the
95
+ // floor must read as critical on ITS OWN row; the closing line below only
96
+ // renders when there is at least one row, so it can never be the sole
97
+ // carrier of this signal.
98
+ const openingCritical = criticalAtOrBelow != null && opening.balance <= criticalAtOrBelow;
99
+
100
+ return (
101
+ <View style={styles.ledger}>
102
+ <View style={styles.openingRow}>
103
+ <View style={styles.grow}>
104
+ <Text size="sm" weight="medium">
105
+ {opening.label}
106
+ </Text>
107
+ {opening.date ? (
108
+ <Text size="xs" color="muted">
109
+ {formatDate(opening.date)}
110
+ </Text>
111
+ ) : null}
112
+ </View>
113
+ <Text size="sm" weight="medium" tabular color={openingCritical ? "danger" : undefined}>
114
+ {signed(format, opening.balance)}
115
+ </Text>
116
+ </View>
117
+ {computed.map(({ row, balance }) => {
118
+ const critical = criticalAtOrBelow != null && balance <= criticalAtOrBelow;
119
+ return (
120
+ <View key={row.id} style={styles.row}>
121
+ <Text size="xs" color="muted" numberOfLines={1} style={styles.date}>
122
+ {formatDate(row.date)}
123
+ </Text>
124
+ <Text size="sm" numberOfLines={1} style={styles.shrink}>
125
+ {row.label}
126
+ </Text>
127
+ {row.reference ? (
128
+ <Link size="xs" onPress={row.reference.onPress} accessibilityLabel={row.reference.label}>
129
+ {row.reference.label}
130
+ </Link>
131
+ ) : null}
132
+ <View style={styles.grow} />
133
+ {/* Neutral, never auto-toned by sign: an OUT movement is routine
134
+ business (a stock issue to a filled order, a payment applied)
135
+ as often as an IN one, and colouring every negative red would
136
+ claim a meaning the number alone does not carry — the same
137
+ restraint `LedgerRow`'s own `tone` leaves to its caller. The
138
+ one signal this component DOES own is `criticalAtOrBelow`,
139
+ which is a real domain rule the caller opted into, not a
140
+ guess from the arithmetic sign. */}
141
+ <Text size="sm" tabular style={styles.amount}>
142
+ {signed(format, row.amount)}
143
+ </Text>
144
+ <Text size="sm" weight="medium" tabular color={critical ? "danger" : undefined} style={styles.balance}>
145
+ {signed(format, balance)}
146
+ </Text>
147
+ </View>
148
+ );
149
+ })}
150
+ {computed.length > 0 ? (
151
+ <View style={styles.total}>
152
+ <Divider />
153
+ <View style={styles.row}>
154
+ <Text size="md" weight="semibold" style={styles.grow}>
155
+ {closingLabel ?? locale.runningLedger.currentBalance}
156
+ </Text>
157
+ <Text size="md" weight="semibold" tabular color={closingCritical ? "danger" : undefined}>
158
+ {signed(format, closing)}
159
+ </Text>
160
+ </View>
161
+ </View>
162
+ ) : null}
163
+ </View>
164
+ );
165
+ }
166
+
167
+ const styles = StyleSheet.create({
168
+ // Mirrors `Ledger`'s own outdent + row rhythm so the two read as one design
169
+ // language wherever a page uses both.
170
+ ledger: { gap: 0, marginHorizontal: -8 },
171
+ row: {
172
+ flexDirection: "row",
173
+ alignItems: "center",
174
+ gap: 10,
175
+ minHeight: 28,
176
+ paddingHorizontal: 8,
177
+ },
178
+ openingRow: {
179
+ flexDirection: "row",
180
+ alignItems: "baseline",
181
+ gap: 10,
182
+ minHeight: 28,
183
+ paddingHorizontal: 8,
184
+ marginBottom: 4,
185
+ },
186
+ grow: { flexGrow: 1, flexShrink: 1 },
187
+ shrink: { flexShrink: 1 },
188
+ date: { width: 72 },
189
+ // Fixed widths so the amount and balance columns each keep ONE right edge
190
+ // down the whole run, whatever an individual figure's digit count — the
191
+ // same reasoning `Ledger`'s single money column applies to two columns
192
+ // instead of one.
193
+ amount: { minWidth: 90, textAlign: "right" },
194
+ balance: { minWidth: 100, textAlign: "right" },
195
+ total: { gap: 6, marginTop: 6 },
196
+ });
package/src/stepper.tsx CHANGED
@@ -23,8 +23,13 @@ import { NODE, STEP_HEAD_TEXT_LINE, STEP_MARKER_GAP, STEP_ROW_INSET, STEP_ROW_PA
23
23
  // A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
24
24
  // where we are (ring + white centre, pulses when live); `done` = passed (filled);
25
25
  // `complete` = the terminal "finished" marker (filled + check); `warning` = an
26
- // issue at a reached step.
27
- export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
26
+ // issue at a reached step; `fail` = a reached step whose OUTCOME is negative —
27
+ // a verdict, not an issue still open. Distinct from `warning`: amber asks the
28
+ // reader to look, red tells them the answer is already no (a QC reject, a
29
+ // failed match) — collapsing the two into one amber "something's off" loses
30
+ // the one bit that actually matters, whether the row is still actionable or
31
+ // already decided.
32
+ export type StepStatus = "upcoming" | "current" | "done" | "warning" | "fail" | "complete";
28
33
  export type StepOrientation = "horizontal" | "vertical";
29
34
 
30
35
  const reached = (s: StepStatus) => s !== "upcoming";
@@ -302,6 +307,14 @@ export function Step(props: StepProps) {
302
307
  {onToggle && mark === "box" ? (
303
308
  // SELECTION: the square, in the ring's own box so the column and the
304
309
  // spine keep their rhythm whichever mark a surface uses.
310
+ // `fail` deliberately reads as unchecked here, not done: neither
311
+ // mark below has a fail-shaped state, and forcing one to `done`
312
+ // would draw a rejected step in the same ink as a passed one —
313
+ // the one distinction a verdict exists to keep. A verdict is
314
+ // reported, not toggled, so a real caller never reaches this
315
+ // combination (`ChecklistItem` withholds `onToggle` once
316
+ // `verdict` is set); this stays correct for anyone driving
317
+ // `Step` directly.
305
318
  <View style={{ width: NODE, height: NODE, alignItems: "center", justifyContent: "center" }}>
306
319
  <CheckboxInput
307
320
  checked={status === "done" || status === "complete"}
@@ -376,6 +389,17 @@ export function Marker({ status, color, live }: { status: StepStatus; color: str
376
389
  </View>
377
390
  );
378
391
  }
392
+ if (status === "fail") {
393
+ // A decided negative: filled in the same solid-red the kit uses for danger
394
+ // everywhere else (deadline.ts's own tone→hue mapping), with a white X —
395
+ // the "done" disc's shape, wrong-answer color, so a scanning eye reads
396
+ // both as "reached and settled" and only the color says which way.
397
+ return (
398
+ <View style={[styles.disc, { backgroundColor: solid("red") }]}>
399
+ <Icon name="x" size={10} color={colors.white} />
400
+ </View>
401
+ );
402
+ }
379
403
  if (status === "current") {
380
404
  return (
381
405
  <View style={styles.discWrap}>