@lotics/ui 44.7.1 → 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 — 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. |
@@ -42,6 +42,16 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
42
42
  panel a few hundred pixels wide lays its contents out for the whole display. (A component that
43
43
  needs a number rather than a bucket — how many columns fit — measures its own box; `Table`
44
44
  does exactly that.) → [catalog.md §Utility hooks](./docs/catalog.md).
45
+ - **No user-facing string is a literal in `src/` when a locale slice already names it.** Every
46
+ string a component renders resolves prop → `useLoticsLocale()` slice → the pack's English
47
+ default. Write it as an allowlist, not a hunt: a literal typechecks, renders, and passes a
48
+ render test in the language it happens to be written in — the kit shipped a Vietnamese ⓘ label
49
+ to every tenant that way, invisible to a whole suite of vi-pack assertions. Assert a wired
50
+ string under the OTHER language. → [composition.md §Microcopy](./docs/composition.md).
51
+ - **Annotate every droppable table column, or none.** A partial `priority` is the trap: an
52
+ unannotated column sorts above the whole hand-written range, so one annotation makes every
53
+ other column drop first, and one unannotated column among annotated ones goes first however
54
+ important it is. → [composition.md §The register's own craft](./docs/composition.md).
45
55
  - **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
46
56
  `primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
47
57
  extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
@@ -62,7 +72,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
62
72
  a press it refuses. Rows tick ONE at a time; derive position from the highest stamp and one click marks
63
73
  everything below it without inventing dates. A checklist REPORTS — a field owned by a section is
64
74
  named as a gap with a `ChecklistNote action` that jumps there, never edited or previewed in the
65
- 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`.
66
78
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
67
79
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
68
80
  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,112 @@ 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
+ ## 44.8.0 — a value column gets its own floor, and eight defaults stop fighting their own docs
8
+
9
+ No API is removed. Three of these change what an existing screen renders, so read them even
10
+ though nothing will fail to compile.
11
+
12
+ **`StepProgress`'s built-in caption is locale-wired, and `LoticsLocale` gains a `stepProgress`
13
+ slice.** A hand-built pack must supply it (both shipped packs already do):
14
+
15
+ ```ts
16
+ stepProgress: {
17
+ complete: (total) => `Hoàn tất (${total}/${total})`,
18
+ stage: (name, index, total) => `${name} (${index}/${total})`,
19
+ none: (total) => `0/${total}`,
20
+ position: (index, total) => `${index} trên ${total}`,
21
+ },
22
+ ```
23
+
24
+ The caption over named stages was built as `Complete (7/7)` / `0/7` in the component, and the
25
+ screen-reader fallback as `4 of 7` — hardcoded English inside a component the kit requires to
26
+ resolve prop → pack → English default. A Vietnamese board read `Complete (7/7)` in its own
27
+ register. Same defect class as the `fileRow`/`sources`/`counter` leak below, and it hid the same
28
+ way: every state renders correctly under `en`, because English is what the bug produces.
29
+
30
+ Each entry is a FUNCTION rather than a word to concatenate, for the reason the earlier entry
31
+ gives — where the number sits in the sentence is a property of the language, and `position`
32
+ is `4 of 7` against `4 trên 7`. Passing `label` still overrides the caption entirely; nothing
33
+ that already passed one changes.
34
+
35
+ **`Finding` stacks its `readings` one per line and drops the ` · ` between them.** No prop
36
+ changes; a two-reading finding is now two lines instead of one.
37
+
38
+ The separator failed twice over. `docs/reviewing.md` bans ` · ` outright — it claims a
39
+ relationship while refusing to name it, and it is the loudest templated-metadata tell there is —
40
+ so an app reaching for the kit's own ranked-insight component failed the kit's own microcopy
41
+ probe with nothing it could do about that. And a middot is the one mark a screen reader drops,
42
+ so the character carrying "these two disagree" never survived being read aloud: exactly the
43
+ defect `CommentsButton` was fixed for. It wrapped badly too — inline + `flexWrap` could start a
44
+ phone-width line WITH the separator, or split a source from its value across lines.
45
+
46
+ Stacked, each `source value` pair is a row at every width and needs no separator to relate them.
47
+ Pass `readings` shorter values, or move a long one to `detail`, if the extra line costs you
48
+ somewhere tight.
49
+
50
+ **A `SequenceItem` with no handlers renders no control column.** Reorder/remove controls still
51
+ render disabled where they cannot act — first item, last item, a row that can be removed but not
52
+ moved — because that is what holds the right edge steady between items that are editable. What
53
+ changed is the item that declares NO editing at all: it used to get three permanently dead
54
+ buttons, which is a dead affordance by `reviewing.md`'s own gates, and a read-only sequence (legs
55
+ already run, a route as recorded) is a common shape rather than an edge case. A list where some
56
+ rows are fully locked and others are editable will now step in the right margin; give the locked
57
+ rows a handler if that matters more than the dead controls did.
58
+
59
+ **`DateStamp` takes `compact`.** Additive, default `false`. It forwards the option `formatDate`
60
+ already had, dropping the year (`08/09`) so the stamp can agree with a neighbouring column the
61
+ caller formats itself. The format stays in `formatDate` — this is a passthrough, not a second
62
+ implementation.
63
+
64
+ **A `DetailTable` stacks earlier, because a value column's floor is no longer a control's.**
65
+ `minValueWidth` defaulted to `MIN_CONTROL_WIDTH` (160) — the width below which a CONTROL stops
66
+ being operable, which is a different question from the width below which a VALUE stops being
67
+ readable. A value column holds company names and addresses. At 150/160 the stack threshold was
68
+ 334, so a 375px screen kept two columns and handed the value ~170px: a name needing 221 got 104.
69
+ The new `MIN_VALUE_WIDTH` (200) is the default; pass `minValueWidth` explicitly to pin the old
70
+ behaviour on a table whose values really are short.
71
+
72
+ **An unannotated table column's drop priority is `columns.length + index`, not `index`.** An
73
+ unannotated register still sheds right-to-left — that is unchanged and covered by a test — but a
74
+ MIXED table now behaves the way its author meant. Annotating your most important column
75
+ `priority: 1` used to TIE it with the unannotated column at index 1, and the right-to-left
76
+ tie-break then dropped yours first: the amount column, the fact the register exists for,
77
+ disappearing at narrow widths on a screen that reviewed as correctly annotated. **Annotate every
78
+ droppable column or none** — a partial annotation is what the old default punished.
79
+
80
+ **A selected `CardSelectItem` carries a GROUND**, not the same ring as hover. It was
81
+ `selected || hovered || pressed || focusVisible` → one style, so the card you had CHOSEN was
82
+ pixel-identical to the card under the pointer. Selection is now `accent_wash` + an accent edge,
83
+ the same "you are here" a selected table row and an active chip carry. This is the fix 43.5.0
84
+ made for `Table`'s selected row, one release late.
85
+
86
+ **`DataGrid`'s flexible column has a floor** (`FLEX_MIN_WIDTH`, 120) instead of `minWidth: 0`.
87
+ This grid sheds no columns by design — every column is a measure you came to compare — so the
88
+ fixed columns held their widths and the flexible identity column absorbed the whole squeeze,
89
+ all the way to zero: a narrow container rendered rows of unlabelled money, silently. Past the
90
+ floor the grid now overflows, and a narrow container needs to scroll it.
91
+
92
+ **`AgentRunLike.error` is OPTIONAL.** It was declared required while its own docblock promised
93
+ it accepted an SDK run's optional `error` — and an optional property is not assignable to a
94
+ required one however wide the required one's type, so the three-line usage printed in
95
+ `ai_patterns.md` did not compile. **If you worked around it** with a `useMemo` reshape or a cast
96
+ (`{ ...run, error: run.error ?? null }`), delete that: hand the SDK's run straight over.
97
+
98
+ **`Select` takes the `D` data generic** its siblings already had, so `optionPicker(...)` spreads
99
+ into it without a cast. Additive.
100
+
101
+ Also additive, nothing to change: `MIN_VALUE_WIDTH` and `FLEX_MIN_WIDTH` are exported (the
102
+ latter via a new `./table_fit` entry point, which also exports `ROW_HEIGHT` and `COLUMN_GAP` so
103
+ a skeleton can be built from the register's own constants); `formatMoney` takes
104
+ `maxFractionDigits` for a genuine per-unit price (`Intl`'s currency style floors at the
105
+ currency's minor unit, so an LCL rate of 0,019 US$/kg printed as `0,02`); `InlineSelect`
106
+ forwards `getOptionDescription` to the two-line option row `OptionList` already renders;
107
+ `NumberInput` takes a `placeholder`; `EmptyState` and `ErrorState` centre their TEXT and not
108
+ only their box (they swap into one another as a read settles, so only one of the two centred
109
+ would have shown up as the copy jumping alignment at the moment a region fails); a
110
+ heading row WRAPS rather than crushing its title; `Accordion`'s meta yields before its title;
111
+ `CardHeaderTitle` reads the ⓘ's name from the locale pack; and `ReferenceField`'s peek footer
112
+ gives its clear the same resting surface as the verb beside it.
7
113
  ## 44.5.0 — `Pagination` drops the page index
8
114
 
9
115
  The pager rendered a range AND a page index — `16–30 of 45` beside `Page 2 of 3`. Both answer
@@ -94,11 +200,10 @@ No API is removed and nothing throws, but four DEFAULTS moved, so every register
94
200
  in your app renders differently on upgrade. Each is reversible at the call site if you need the
95
201
  old look while you catch up.
96
202
 
97
- **Table column headers are sentence case, not all-caps** and one ink step lighter
98
- (`zinc-500`). `Table` and `SortHeader` were uppercasing every column label, which the heading ramp
99
- bans and names the #1 templated tell. The ink step is not cosmetic and cannot be skipped: at
100
- `muted` the header matched a row's supporting line in size, weight AND colour, so dropping the
101
- case alone leaves it reading as a stray label rather than as the band naming the columns.
203
+ **Table column headers are sentence case, not all-caps.** `Table` and `SortHeader` were
204
+ uppercasing every column label, which the heading ramp bans and names the #1 templated tell. What
205
+ keeps the band from reading as a stray label once the case is gone is WEIGHT: the header is
206
+ `sm muted medium`, and a row's supporting line is `sm muted` regular, so the two never collide.
102
207
 
103
208
  **Row separators are lighter than the header rule.** The band capping a table keeps
104
209
  `colors.border`; the hairlines between rows are `zinc-100`. Every rule used to be `border`, which
@@ -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
@@ -222,6 +233,13 @@ Two columnar shapes, and the choice is about data size:
222
233
  heading, so digits line up under their label; **`DataGridColumn.footer` gives that column its
223
234
  cell in the group's footer row** (a subtotal, a resolved measure) — the grid lays it out in
224
235
  the column's own box, so a total can never drift from what it sums and no consumer rebuilds
236
+ **`DataGrid` SHEDS NOTHING at narrow widths, and that is deliberate** — every column is a
237
+ measure you came to compare, unlike `Table`'s register of objects where the row is an index
238
+ and the record holds the rest. So it has no `priority` and no stacked mode: fixed columns keep
239
+ their widths, the flexible column holds a `FLEX_MIN_WIDTH` floor, and past that the grid
240
+ OVERFLOWS. A narrow container must scroll it horizontally; the floor exists because without
241
+ one the flexible identity column absorbed the entire squeeze down to zero and the grid
242
+ rendered rows of unlabelled numbers, silently. If you want columns to drop, you want `Table`.
225
243
  the row. `renderGroupFooter` remains for content that does NOT sit in a column (a per-group
226
244
  add row). The grid takes exactly its container's width — no outdent to fight, so it needs no
227
245
  scroller unless YOU add one. It renders ALL rows (no
@@ -376,7 +394,9 @@ your screen. See the AI-patterns doc indexed in [AGENTS.md](../AGENTS.md) for th
376
394
  `ChoiceList` options), `Sources` (provenance chips for AI output — at review scale,
377
395
  `label={null}` slots the chips at a section's bottom), `Finding` (one ranked insight from an
378
396
  AI check, laid out HORIZONTALLY: a severity dot, the title, and the `delta` right-aligned on
379
- that same line, over one dim line of `readings` `source value · source value`, both kept on
397
+ that same line, over the `readings` stacked ONE PER LINE — each `source value`, no separator
398
+ joining them (a middot claims a relation it will not name, and is the one mark a screen reader
399
+ drops), both kept on
380
400
  screen with NEITHER marked as the wrong one, since a finding reports that two sources disagree
381
401
  and which is right is the reader's call. `detail` is the optional consequence. A reader scanning
382
402
  a stack gets severity down the left edge and magnitude down the right. The readings ARE the
@@ -542,6 +562,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
542
562
  one interaction it signals; the padding still comes off the 8-grid, the ring is only
543
563
  the floor), `HOVER_BORDER`, `CONTROL_TRANSITION`, `chipSurfaceStyle` — the shared
544
564
  control-surface tokens.
565
+ - **`table_fit`** — the register's shared geometry, importable so a skeleton or a
566
+ hand-built band derives from the same numbers `Table` lays out with: `ROW_HEIGHT` (72),
567
+ `COLUMN_GAP`, `ROW_GUTTER`, `FLEX_MIN_WIDTH` (120 — what a flexible column needs to stay
568
+ usable), plus `computeTableFit` itself. `MIN_VALUE_WIDTH` (200 — a record VALUE column's
569
+ floor, deliberately above a control's `MIN_CONTROL_WIDTH`) lives in `control_surface`.
545
570
  - **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
546
571
  every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
547
572
  to system fonts.
@@ -1150,7 +1175,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1150
1175
  `Destination`) cannot hold a second middle entry and cannot say the entries are ordered.
1151
1176
  `SequenceItem` takes the content, an optional `role` (DERIVE it from index — a stored role lies
1152
1177
  the moment the list is reordered) and `onMoveUp`/`onMoveDown`/`onRemove`; the controls render
1153
- even where they cannot act so the right edge never shifts between items. Reorder is BUTTONS,
1178
+ even where they cannot act so the right edge never shifts between items but an item passing
1179
+ NO handler at all renders no control column, so a read-only sequence (legs already run, a route
1180
+ as recorded) is a first-class shape rather than three dead buttons per row. Reorder is BUTTONS,
1154
1181
  not a drag handle — dragging is invisible to the keyboard and fiddly on a phone, and a
1155
1182
  three-to-six position list does not need it. Indent anything that follows the list (an "Add"
1156
1183
  link) by `SEQUENCE_INSET` so it lands on the rail's column. Distinct from `Timeline` (an
@@ -1182,8 +1209,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1182
1209
  before acting (amber, announced); **`error`** = field-level failure under the value, danger +
1183
1210
  alert semantics (the `Inline*` editors already render their own transient save errors — don't
1184
1211
  wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
1185
- on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
1186
- control band's slack so its gap matches a chip row's.
1212
+ on a value sitting OFF the inline-control grid (a raw node NOT `InlineStatic` / `InlineSlot`,
1213
+ which are on it) aligns the annotation to it on both axes: drops the control text inset and
1214
+ tucks up by the band's slack.
1187
1215
  The three annotations are ONE anatomy shared with a checklist row's `ChecklistNote` — same
1188
1216
  order, same alert semantics — because a record's field and a task's field are the same thing
1189
1217
  on two surfaces.
@@ -1249,6 +1277,30 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1249
1277
  trust by summing exactly what its label claims. `LedgerBasis` is demoed on the gallery's
1250
1278
  Charts page instead — a delivery order has no base to compute from, and inventing one to
1251
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.
1252
1304
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
1253
1305
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
1254
1306
  like the editors above and below it), whose press opens a PEEK of that record's facts.
@@ -1257,7 +1309,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1257
1309
  panel narrower than the control that opened it reads as belonging to something else. So the
1258
1310
  field truncates its name to one line and the peek's header WRAPS the whole of it: the column
1259
1311
  stays scannable, the full value is one press away. Between the floor and `labelWidth +
1260
- MIN_CONTROL_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width.
1312
+ MIN_VALUE_WIDTH + 24` a `DetailTable` stacks, which is the right grammar at that width — the
1313
+ VALUE floor (200), not the control floor, because that column holds names as well as editors.
1261
1314
  The verbs live INSIDE the
1262
1315
  peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
1263
1316
  dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
@@ -1311,6 +1364,22 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1311
1364
  reference is a FIELD, not a section — one pointer to one other row never earns a heading
1312
1365
  and a rail entry. Worked example: [`tpl_record`](../examples/tpl_record.tsx) § General
1313
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.
1314
1383
  - **`peek`** — `Peek`: drill-down for a reference — press a name/id where it appears and get its
1315
1384
  details in an anchored popover, without leaving the screen; keep the content a summary with ONE
1316
1385
  action to the full record. **`variant` picks the register the TRIGGER sits in, and getting it
@@ -1350,6 +1419,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1350
1419
  On pressable rows dropped values stay one tap away — the row opens the record; a READ-ONLY
1351
1420
  register (rows without `onPress`) has no door, so give the columns it can't afford to lose
1352
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.
1353
1429
  **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.** A
1354
1430
  register answers "which one"; the moment it cannot answer "and what about it" the reader is
1355
1431
  sent to a drawer, loses the surrounding rows, and pays a navigation to come back — which is
@@ -1374,6 +1450,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1374
1450
  position (a group band, a totals line) and the gutter stays blank so the cells beside it stay
1375
1451
  in line. A position is NEVER an identity: sort or filter and row 12 is a different record —
1376
1452
  a stable handle is a column, and it leads the cells. Worked screen: `tpl_item_list`.
1453
+ - **`tabs`** — a tab may state its band's size with `TabOption.count`, and needs attention with
1454
+ `TabOption.status` (a `ColorName` dot). Pressing the tab IS the filter, so the count is also the
1455
+ reason to press it. → [composition.md](./composition.md) §"A partitioning control states its band's size".
1377
1456
  - **`table`** rhythm — rows are separated by their own height and the hover wash, never by a hairline; the register's one line is the band capping the columns. 72px rows, overridable per row with `TableRow`'s `minHeight` when a surface genuinely needs density. There is no `spacing` prop: a table of VALUES rather than of objects is a different component (`Matrix`, `DataGrid`). → [composition.md](./composition.md) §"The register's rhythm".
1378
1457
  - **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot). Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
1379
1458
  - **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
@@ -1435,6 +1514,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1435
1514
  empty — and a ladder of fields is a form with invisible borders. Reach for `InlineDatePicker`
1436
1515
  when a person is expected to ENTER the date, and this when the system already knows it.
1437
1516
  Empty renders a thin rule, not a placeholder; read-only (omit `onChange`) renders text alone.
1517
+ `compact` drops the year (`08/09`) — the stamp shares a row with dates the caller formats, and
1518
+ the option goes straight through to `formatDate`, which stays the one place deciding what a
1519
+ date looks like.
1438
1520
  `onChange` fires on CLOSE, not on each pick — one commit per visit, and a dismissed panel
1439
1521
  still writes whatever was picked in it, which is what makes a single tap enough.
1440
1522
  Worked screen: `examples/tpl_record.tsx` § Progress.
@@ -1606,7 +1688,9 @@ component rather than showing it at zero.
1606
1688
  status mix on one bar).
1607
1689
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
1608
1690
  (valuenow/min/max) because its segments are decoration over ONE quantity — the opposite
1609
- case from `stepper`, whose steps carry content a `progressbar` would hide.
1691
+ case from `stepper`, whose steps carry content a `progressbar` would hide. Pass the stage
1692
+ NAMES and it captions itself from the `stepProgress` locale slice ("Hạ bãi (2/4)"); `label`
1693
+ overrides, `captionTone="danger"` is the documented stalled-stage slot.
1610
1694
  - **`breakdown`** — `Breakdown`: a stacked bar + ranked share rows, pressable to drill;
1611
1695
  `maxRows` folds the long tail behind a localized "Show N more" toggle
1612
1696
  (`BreakdownLabels`).
@@ -1632,7 +1716,13 @@ component rather than showing it at zero.
1632
1716
  unsorted and out-of-bounds ranges all normalize; no input drops a character.
1633
1717
  - **`legend_item`** — `LegendItem`: one swatch + label of a chart legend.
1634
1718
  - **`remainder_meter`** — `RemainderMeter`: allocated-vs-remaining meter
1635
- (`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.
1636
1726
  - **`allocation_row`** — `AllocationRow`: one target's row in an allocation surface (pairs
1637
1727
  with `RemainderMeter`).
1638
1728
 
@@ -2019,7 +2109,9 @@ component rather than showing it at zero.
2019
2109
  column quietly acquires TWO left edges and two baselines, and `DetailRow`'s label (which pads
2020
2110
  down to meet a control) agrees with one kind of row and not the other. `InlineStatic` is the
2021
2111
  STRING case and composes it; reach for the slot when the value is a node, which a review
2022
- surface always is. NOT for a table cell a `Table` sets its own row rhythm.
2112
+ surface always is. An ANNOTATED row needs the grid even with no editor in sight, because the
2113
+ annotation stack is inset to a control's words — data_entry.md §The editability affordance
2114
+ has the per-table test. NOT for a table cell — a `Table` sets its own row rhythm.
2023
2115
  - **`use_change_set`** — **`useChangeSet(ids, { initial })`**: the accept/reject/undo
2024
2116
  bookkeeping as a HOOK, zero layout — `status`/`accept`/`reject`/`undo`/`acceptAll`/`reset`,
2025
2117
  `accepted`/`rejected`/`pending`, `keptCount`/`total`/`settled`. Decisions are stored as