@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.
@@ -8,6 +8,23 @@ full worked-example screens ship as source in `examples/tpl_*.tsx` (picked by jo
8
8
  [templates](./templates.md)). Every rule here is enforced by the primitives' defaults; fighting
9
9
  them produces off-system UI.
10
10
 
11
+ ## The form comes before the treatment
12
+
13
+ **Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
14
+ through ordered places is a rail; work assigned to finite resources over time is a board; a
15
+ sequence of events is a timeline; a population whose point is its deviant minority is a queue
16
+ sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
17
+ should have been a rail is well-typed, well-spaced, correctly aligned and still says nothing about
18
+ order or progress, because a status column reports one word and leaves the reader to know which
19
+ word comes after which.
20
+
21
+ The pull toward a register is structural, not a lapse in taste: `Table`, `DetailRow`,
22
+ `SummaryLine` and `Badge` are the most reachable things here, and they compose into something
23
+ defensible for almost any data. That is exactly why the question has to be asked out loud. **Reuse
24
+ first is about not duplicating primitives** — a one-off composition needs no new component, so the
25
+ kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
26
+ subject into rows.
27
+
11
28
  ## Canvas & content column
12
29
 
13
30
  - **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28`. Cards separate from the
@@ -98,7 +115,15 @@ restyle a heading level per-page.
98
115
  (`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
99
116
  at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
100
117
  heading row's right edge (the title's `flex: 1` pushes them) — see the add-placement law
101
- 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,
102
127
  paged, or long enough that the reader would have to scan to know. Over a list or thread
103
128
  rendered whole directly beneath its own heading it restates what is already in view, and the
104
129
  heading reads busier for it. The page column is a **`SectionStack`** — it owns the
@@ -183,6 +208,14 @@ without scanning. The heading row is the section's control line — it already c
183
208
  and the view controls — so the add belongs on it, in the same spot whether the section holds
184
209
  nought or forty.
185
210
 
211
+ **When the row runs out of width, the VERB wraps — the title never gives way.** The title names
212
+ the section; the verb is the thing that can move. Getting this backwards is easy to ship because
213
+ RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title beside a button that
214
+ holds its intrinsic width is the half that collapses — measured at 375 as a heading broken one
215
+ word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
216
+ their title column for this; do not add a second `flex: 1` spacer beside a title that already has
217
+ one, which halves its share again and is how a badge came to land on top of a section title.
218
+
186
219
  **Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
187
220
  bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
188
221
  to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
@@ -217,6 +250,18 @@ no path actually closes.
217
250
  - **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
218
251
  selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
219
252
 
253
+ ## A partitioning control states its band's size as a PROP
254
+
255
+ `TableGroup` takes `count`; `Tabs` takes it per option. Neither takes a label with the number
256
+ formatted into it, and that is the whole rule: `"Trên tàu (4)"` is a second copy of a figure the
257
+ screen already computes, and it is the copy that goes stale — the band re-counts on every filter
258
+ and the string does not.
259
+
260
+ It also decides where the count may live. A register whose bands already state their sizes must
261
+ not carry a strip of chips above it repeating the same three numbers; that is one fact twice, and
262
+ the reader learns to skip the strip. And a count is a reason to PRESS — a control that states how
263
+ many rows are behind it and does not filter to them is stating a fact it refuses to act on.
264
+
220
265
  ## Period filters for time-constrained data
221
266
 
222
267
  Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
@@ -312,7 +357,11 @@ compete instead of reading as different things. Selection changes what the box I
312
357
  hover changes how intensely it says it.
313
358
 
314
359
  For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
315
- open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. The bug this
360
+ open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
361
+ is load-bearing rather than tidy: a caller's resting `borderColor` is exactly how a frameless
362
+ variant gets expressed, and layered the other way it erases the hover edge that is such a field's
363
+ ONLY affordance — a control that then draws nothing in either state and is simply never pressed.
364
+ Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
316
365
  grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
317
366
  greys *and* animates its edge while its siblings only border-hover. Reach for `PressableHighlight`
318
367
  on a bordered/input control and you've made it.
@@ -416,6 +465,12 @@ a dashboard. Card stat rails use `KPICard`.
416
465
  form round-trip never expands them to a fabricated `"2026-05-01"`. Comparisons and sorting treat
417
466
  a partial as its period start (`"2026" < "2026-05" < "2026-05-01"`).
418
467
 
468
+ **A per-unit price is not a total, and the currency's minor unit is the wrong floor for it.**
469
+ `Intl`'s currency style rounds to two decimals for USD, so a rate of 0,019 per kg prints as
470
+ `0,02` — a 5% error rendered as fact, with nothing on the screen saying it was rounded. Pass
471
+ `maxFractionDigits` on a UNIT price; leave it off for a total, where two decimals is the contract.
472
+ Never hand-roll the grouping or the symbol to get the digits back.
473
+
419
474
  ## Every number is a door
420
475
 
421
476
  Except the KPI strip: a component that summarizes records leads to the records behind it when
@@ -712,6 +767,21 @@ action goes to the referenced record's own page; a local `Drawer` that re-shows
712
767
  just showed makes "Open" mean *read that again, wider*, and leaves the real record unreachable
713
768
  from the only control that promised it.
714
769
 
770
+ **A record surface answers *which record is this* first — and the block that does it is SHORT.**
771
+ Identity is the handful of facts someone would say aloud to name this record (its number and its
772
+ counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
773
+ reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
774
+ labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
775
+ that leaves a reader who cannot tell whose record they are in. Both orders measure perfectly, so
776
+ neither is caught by any check that reads the rendered screen. **When a leading block buries the
777
+ answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
778
+ fact can still be the one that names the thing.
779
+
780
+ "The chrome already carries identity, so the body need not lead with it" is a real argument and a
781
+ falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
782
+ number and its totals carries half an identity; the counterparty is missing, and half is what
783
+ makes the claim feel true while the reader still cannot name what they are looking at.
784
+
715
785
  **All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
716
786
  stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
717
787
  never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
@@ -928,10 +998,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
928
998
  alignment law: the row top-aligns and label, control, trailing each center within the
929
999
  first control line — a tall value block never drags the label, and a LABEL too long for its
930
1000
  column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
931
- the value. A FLAT value row
932
- (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
933
- control band's slack so the perceived gap under the text equals the gap under a chip
934
- the band's invisible bottom half must never read as a hole above the description.
1001
+ the value. A value that sits OFF the
1002
+ inline-control grid — a raw node, not an `InlineStatic` / `InlineSlot` sets **`DetailRow
1003
+ flat`**, which aligns its annotations on both axes: they drop the control's text inset so the
1004
+ description starts on the value's own left edge, and tuck up by the band's slack so the gap
1005
+ under the text matches the gap under a chip. Miss it and the row has three left edges and a
1006
+ hole; pass it on a value that IS on the grid and you create both. Which one a static value
1007
+ should be is decided per table — see data_entry.md §The editability affordance.
935
1008
  - **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
936
1009
  this reference, call this number, open this record: the act names the value, so it travels
937
1010
  with what it acts on, and a reader never has to pair a control sitting at the page's right
@@ -1243,6 +1316,15 @@ which is the loudest possible version of the thing the rule exists to stop. Remo
1243
1316
  without replacing the separation is what left the header looking adrift — the ink step above is
1244
1317
  the replacement, and the two changes only work together.
1245
1318
 
1319
+ **Annotate every droppable column, or none.** `priority` decides what a narrow container sheds
1320
+ (lowest number is last to go, and column 0 — the identity — never drops). A PARTIAL annotation is
1321
+ the trap: an unannotated column defaults above the whole hand-written range, so one annotated
1322
+ column silently makes every other column drop before it, and one unannotated column among
1323
+ annotated ones is the first to go however important it is. Both read as correct in review. The
1324
+ question to answer per register is "what is the last column standing", and the answer is almost
1325
+ always the figure the register exists to compare — so give that one the lowest number and work
1326
+ outwards.
1327
+
1246
1328
  **A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
1247
1329
  `##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
1248
1330
  at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
@@ -1495,6 +1577,12 @@ disclosure exception). `TextLink` takes the blue only when it has an `href`; as
1495
1577
  a value, or as a muted in-place disclosure, it stays neutral — because a press that opens a PEEK,
1496
1578
  or unfolds a transcript, is not a trip.
1497
1579
 
1580
+ **Selection is a GROUND, and hover is a ring — at every altitude.** A row, a card, a chip, a
1581
+ tile: the thing you have CHOSEN carries the accent wash, and the thing under the pointer carries
1582
+ the ring. Collapsing them into one "active" style makes the selected item pixel-identical to the
1583
+ hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
1584
+ is a single boolean OR-ing selection together with hover, press and focus.
1585
+
1498
1586
  ## `Badge` is for STATUS only — everything else is text
1499
1587
 
1500
1588
  A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
@@ -1610,6 +1698,12 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
1610
1698
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
1611
1699
  hierarchy, not cramming.
1612
1700
 
1701
+ **A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
1702
+ Padding that happens to sum to the band height is a second owner of that number, and it drifts the
1703
+ moment the type ramp moves — which the ramp is allowed to do. The signature is one control
1704
+ standing a few pixels proud of every sibling in a filter bar, correct on the day it was written
1705
+ and wrong after a release that touched no control at all.
1706
+
1613
1707
  **The trim test — does this element RESTATE something already on the screen?** Run it over every
1614
1708
  screen before shipping: a per-row proportion bar when the percentage is already on the row; the
1615
1709
  percentage itself when the column holds four values and a total; a state line that only says
@@ -66,6 +66,13 @@ Controlled inputs make derived state track what is on screen, and read shorter t
66
66
  they replace — four `DetailRow` + `Inline*` pairs whose `onSave` exists only to `setState` collapse
67
67
  to four fields. Worked example: `tpl_record`'s New-customer dialog.
68
68
 
69
+ **The discriminator, since the kit's own `tpl_item_list` fills preview cards with `Inline*`
70
+ editors:** what decides is whether anything on the surface READS the draft before the commit. A
71
+ dialog whose Save is gated on the values being typed, or whose visible totals derive from them,
72
+ takes controlled `FormField`s — that is this rule. A REVIEW surface whose gate is decided by
73
+ something else (which cards are kept, which candidate is picked) may keep inline editors, because
74
+ nothing is arguing with the box under the cursor. Neither shape is a create form's stand-in.
75
+
69
76
  **But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite, and the
70
77
  opposite failure to the one above. Validity and the error MESSAGE are different questions. Validity
71
78
  gates the commit and is checked continuously; the message claims the reader did something wrong, so
@@ -313,19 +320,66 @@ where it must stay strictly inside its box — that is `framed`. A DISABLED `fra
313
320
  box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
314
321
  fields beside it.
315
322
 
323
+ **`bare` puts its whole affordance in the hover edge, so nothing may outrank that edge.** Pass the
324
+ variant as a PROP; never express it as a resting `borderColor: "transparent"` in `style`, which is
325
+ applied after the hover rule and erases the edge one line after it is computed. The failure is
326
+ silent in every direction that usually catches things — the field keeps its role, its label, its
327
+ focus ring, its metrics and its save — and what breaks is only that nobody presses it, because a
328
+ control drawing nothing at rest and nothing on hover is text. The kit's own `TextInputField` had
329
+ it, so a `bare` inline editor in a register column went un-pressed rather than mis-styled.
330
+
331
+ **The cursor is the other half of that promise, and it belongs to the CONTROL, not to the page.**
332
+ An inline editor takes the pointer like every other control. It once forced the arrow back, on the
333
+ reasoning that an editor is an input rather than a button — right that it is not a button, wrong
334
+ about the replacement: the arrow is what inert prose gets, so the one value on the row that was
335
+ the reader's to change was the one that looked least touchable. The I-beam is not the alternative
336
+ either; a resting editor sets `userSelect: "none"` so a drag edits rather than selects, and an
337
+ I-beam would promise a caret that is not there. A field is disabled is the only case that reverts
338
+ to the arrow, for the same reason it rests bare: an inert value must promise nothing.
339
+
316
340
  A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
317
341
  **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
318
342
  border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
319
343
  glance and the static value never reads as a disabled input.
320
344
 
321
- **Only in a grid that MIXES the two.** "Copies the editor box metrics" means it hard-sets
322
- `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight beside real editors, pure waste
323
- where there are none. In an ALL-static grid (a peek's facts, a summary popover) it reserves a 40px
324
- control band for a 20px value, and the row is the MAX of that box and the table's own band, so
325
- neither fix works alone: use plain `Text` for the value AND pass `DetailTable` a `minHeight` (28,
326
- `DetailRow`'s own default). Same test as `labelWidth` a `DetailTable`'s defaults are the
345
+ **In a grid that MIXES the two, or one that ANNOTATES.** "Copies the editor box metrics" means it
346
+ hard-sets `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight, waste where there is
347
+ no column to hold. Two different things can make the column, and missing the second is what put
348
+ three left edges on a review dialog:
349
+
350
+ - **An editor beside it.** The obvious case: a static value rendered raw beside a real editor
351
+ starts at the cell edge, 20px tall, at the top, while the editor's text starts 9px in and
352
+ centres in a 40px band.
353
+ - **A value column's floor is not a control's floor.** `minValueWidth` decides when the table
354
+ gives up two columns and stacks, and it defaults to `MIN_VALUE_WIDTH` (200) rather than a
355
+ control's `MIN_CONTROL_WIDTH` (160), because the column holds company names and addresses, not
356
+ only editors. The narrow-width failure is silent in both halves: the table technically fits, so
357
+ it does not stack, and every value is then truncated past half while the label beside it measures
358
+ perfectly. Raise it further where a cell holds MORE than one editor; lower it only where the
359
+ values genuinely are short.
360
+
361
+ **A `description` / `warning` / `error` under it.** `FieldAnnotations` insets its stack by
362
+ `CONTROL_TEXT_INSET` so it lines up with a CONTROL's words — so an annotated row whose value is
363
+ raw puts the value at the cell edge and its own description 9px inside it. Nothing else on the
364
+ screen is wrong, the drift is small enough to read as sloppiness rather than structure, and it
365
+ appears only on the rows that happen to carry a description, so a column slides in and out of
366
+ alignment down its own length.
367
+
368
+ Decide it per TABLE, never per row: one annotated static row puts every static value in that table
369
+ on the grid, or the table keeps two row heights for no reason a reader can see.
370
+
371
+ In an all-static, un-annotated grid (a peek's facts, a summary popover) the grid buys nothing and
372
+ costs 40px of band for a 20px value — and the row is the MAX of that box and the table's own band,
373
+ so neither fix works alone: use plain `Text` for the value AND pass `DetailTable` a `minHeight`
374
+ (28, `DetailRow`'s own default). Same test as `labelWidth` — a `DetailTable`'s defaults are the
327
375
  inline-control grid's; a grid that isn't one overrides both.
328
376
 
377
+ `DetailRow flat` is the ESCAPE from the grid, not a synonym for read-only: it says "this value is
378
+ a raw node sitting off the control band", and it corrects the annotations on both axes for it —
379
+ dropping the text inset and tucking the block up by the band's slack. Never pass it with
380
+ `InlineStatic` or `InlineSlot`, which are ON the grid: there it re-introduces the 9px drift it
381
+ exists to remove and pulls the description 4px too tight.
382
+
329
383
  ### Choosing a CHOICE control — by option count, not by taste
330
384
 
331
385
  A field whose value is one-of-N has three answers, and the wrong one is what makes a record
package/docs/reviewing.md CHANGED
@@ -45,10 +45,35 @@ the same direction. The kit measures the CONTAINER, not the screen — see AGENT
45
45
 
46
46
  ---
47
47
 
48
- ## Two gates, before any probe
48
+ ## Three gates, before any probe
49
49
 
50
- A perfectly-treated element that should not exist still measures clean, and a wrong control cannot
51
- be treated into a right one. Both of these are cheap and both outrank everything below.
50
+ A screen in the wrong FORM measures clean in every direction, a perfectly-treated element that
51
+ should not exist still measures clean, and a wrong control cannot be treated into a right one.
52
+ All three are cheap and all three outrank everything below.
53
+
54
+ ### 0. RIGHT FORM
55
+
56
+ **Name what the subject IS before composing anything.** A set of records is a register. A thing
57
+ moving through ordered places is a rail. Work assigned to finite resources over time is a board.
58
+ A sequence of events is a timeline. A population whose point is its deviant minority is a queue
59
+ sorted by deviation. A quantity across two dimensions is a matrix.
60
+
61
+ This gate exists because **every probe below reads a screen that is already a register of fields**
62
+ and measures its TREATMENT. So a screen that should have been a board or a rail passes the type
63
+ inventory, the ground tally, the gaps, the rules and the alignment — all of them — and still reads
64
+ as templated. No amount of treatment fixes it, and nothing else here can see it.
65
+
66
+ *The tell that it was skipped:* every screen in the product is a register with a summary line over
67
+ it. That is what reuse-first produces when nothing asks the question — the catalog's most reachable
68
+ parts are `Table`, `DetailRow`, `SummaryLine` and `Badge`, so a subject whose natural shape is not
69
+ a table gets flattened into one and no probe fires. **Reuse-first is about not duplicating
70
+ PRIMITIVES, not about the shape of a screen**: a one-off composition needs no new component, so
71
+ "the catalog has no journey rail" is an argument for composing one out of `Stepper`, not for
72
+ rendering a status column and moving on.
73
+
74
+ *And check the form against what the data can FILL.* A rail with no stamped stops, a calendar with
75
+ no durations, a map with no coordinates — each renders an empty device that measures perfectly.
76
+ → probe 8.
52
77
 
53
78
  ### 1. SUBTRACT
54
79
 
@@ -244,6 +269,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
244
269
 
245
270
  - **Open what the row opens** and measure where the actionable field LANDS. An UNBOUNDED section
246
271
  (a feed, a thread) above a bounded one buries it, and the burial deepens with use.
272
+ - **Name the record before you read it.** Collect the y of each fact that IDENTIFIES what is open
273
+ (its number, its counterparty) and compare against the viewport; any of them first answered
274
+ below the fold is the finding. Then measure the other direction — the top of the block answering
275
+ what the reader OPENED this for, and what sits above it. A leading block that pushes that answer
276
+ under the fold is too LONG, not mis-positioned: moving it down trades a buried ledger for a
277
+ reader who cannot tell whose record they are in, and both orders measure clean on their own, so
278
+ nothing that reads a resting screen catches either. Re-measure at 375, where a block that cleared
279
+ the fold at 1280 buries the answer. → [composition.md](./composition.md) §"Master-detail".
247
280
  - **Press every control** and look at the state you land in. Commonest miss: a value rendering as
248
281
  a coloured chip in a register and as bare grey text in its own EDITOR.
249
282
  - **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
@@ -275,6 +308,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
275
308
  no option colours). The fix is often config or a write path, not CSS.
276
309
  - **The obvious column may be the empty one.** Reading only the field the composer writes ships a
277
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.
278
319
  - **A fixture can fail in the generous direction too**: an image tile pointed at a dead URL renders
279
320
  nothing, so the chip designed to survive a photo was never tested against one.
280
321
  - **Grep rendered text for internal identifiers — first probe, every audit** —
@@ -47,7 +47,6 @@ import { Confidence } from "@lotics/ui/confidence";
47
47
  import { CardSelectItem } from "@lotics/ui/card_select_item";
48
48
  import { FileDropzone } from "@lotics/ui/file_dropzone";
49
49
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
50
- import { Link } from "@lotics/ui/link";
51
50
  import { TextInputField } from "@lotics/ui/text_input_field";
52
51
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
53
52
  import { InlineFiles } from "@lotics/ui/inline_files";
@@ -847,10 +846,15 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
847
846
  shape per card is found without reading the column. */}
848
847
  <DiffMark kind={dropped ? "removed" : "added"} />
849
848
  <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>{title}</Text>
849
+ {/* A control, not a Link. These MUTATE the change set;
850
+ they navigate nowhere. Blue underlined text promises a
851
+ destination, and `Link` announces `role="link"` — so a
852
+ template every app copies was teaching the one thing
853
+ composition.md § Buttons bans outright. */}
850
854
  {dropped ? (
851
- <Link size="sm" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`}>Keep</Link>
855
+ <Button color="secondary" title="Keep" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`} />
852
856
  ) : (
853
- <Link size="sm" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`}>Don&rsquo;t create</Link>
857
+ <Button color="secondary" title="Don’t create" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`} />
854
858
  )}
855
859
  </View>
856
860
  {/* A dropped card COLLAPSES rather than dimming in place:
@@ -997,7 +1001,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
997
1001
  {khach}
998
1002
  {CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " (new customer)"}
999
1003
  </Text>
1000
- <Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
1004
+ <Button color="secondary" title="Change" onPress={() => setKhach("")} accessibilityLabel="Change customer" />
1001
1005
  </View>
1002
1006
  )}
1003
1007
  </FormField>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.7.1",
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,11 +209,13 @@
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",
214
216
  "./skeleton": "./src/skeleton.tsx",
215
217
  "./table": "./src/table.tsx",
218
+ "./table_fit": "./src/table_fit.ts",
216
219
  "./data_grid": "./src/data_grid.tsx",
217
220
  "./detail_row": "./src/detail_row.tsx",
218
221
  "./record_summary": "./src/record_summary.tsx",
package/src/accordion.tsx CHANGED
@@ -150,8 +150,13 @@ export interface AccordionMetaProps {
150
150
  /** Right-side context for AccordionHeader — an amount, date, or short hint.
151
151
  * xs muted, tabular for numerals. */
152
152
  export function AccordionMeta(props: AccordionMetaProps) {
153
+ // The meta yields before the title does. The title NAMES the disclosure; the
154
+ // meta only qualifies it — but with the title on `flex: 1` and the meta at its
155
+ // intrinsic width, the squeeze landed entirely on the name (measured at 375:
156
+ // title clipped 24% and ellipsised, meta untouched). Same inverted priority
157
+ // `PageHeader` fixed one rung up; resolved the same way here.
153
158
  return (
154
- <Text size="xs" color="muted" tabular>
159
+ <Text size="xs" color="muted" tabular numberOfLines={1} style={styles.meta}>
155
160
  {props.children}
156
161
  </Text>
157
162
  );
@@ -196,6 +201,9 @@ const styles = StyleSheet.create({
196
201
  titleText: {
197
202
  flex: 1,
198
203
  },
204
+ meta: {
205
+ flexShrink: 1,
206
+ },
199
207
  // Flush with the header's left edge — aligns with the title, like a Section
200
208
  // body. The chevron + position already say "inside"; no indent needed.
201
209
  content: {
@@ -48,11 +48,13 @@ export interface AgentRunLike {
48
48
  * the wrong document, and the only exit was to sit through it.
49
49
  */
50
50
  cancel: () => void;
51
- /** The breaking error that killed the run. Accepts `undefined` as well as
52
- * `null` so an SDK run — whose `error` is an OPTIONAL property satisfies
53
- * this without the caller reshaping the object the SDK docs say to hand
54
- * straight over. The pane normalizes both to absent. */
55
- error: string | null | undefined;
51
+ /** The breaking error that killed the run. OPTIONAL, not merely widened to
52
+ * include `undefined`: an optional property is not assignable to a required
53
+ * one however wide the required one's type, so declaring it required made the
54
+ * three-line usage in `ai_patterns.md` fail to compile against an SDK run —
55
+ * and every app that hit it reached for a `useMemo` reshape or a cast. The
56
+ * pane normalizes absent, `undefined` and `null` alike. */
57
+ error?: string | null | undefined;
56
58
  }
57
59
 
58
60
  export function AgentRunScope({ children }: { children: ReactNode }) {
package/src/card.tsx CHANGED
@@ -3,6 +3,7 @@ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Divider } from "./divider";
5
5
  import { InfoPopover } from "./info_popover";
6
+ import { useLoticsLocale } from "./locale";
6
7
  import { Text } from "./text";
7
8
 
8
9
  interface CardProps {
@@ -84,13 +85,18 @@ export interface CardHeaderTitleProps {
84
85
  * description line. Grows to push siblings (meta, actions) to the right.
85
86
  * Tabular numerals so id-like titles (SR-2026-0081) align across a list. */
86
87
  export function CardHeaderTitle(props: CardHeaderTitleProps) {
88
+ // The ⓘ's name comes from the pack, like every other one in the kit
89
+ // (`section_heading` at all three of its levels). This had the Vietnamese
90
+ // value of that very slot pasted in as a literal, which typechecks, renders,
91
+ // and announces Vietnamese to a screen reader in every other tenant's app.
92
+ const words = useLoticsLocale();
87
93
  return (
88
94
  <View style={styles.headerTitle}>
89
95
  <View style={styles.headerTitleRow}>
90
96
  <Text size="sm" weight="semibold" tabular>
91
97
  {props.children}
92
98
  </Text>
93
- {props.info ? <InfoPopover text={props.info} accessibilityLabel="Giải thích dữ liệu" /> : null}
99
+ {props.info ? <InfoPopover text={props.info} accessibilityLabel={words.sectionHeading.info} /> : null}
94
100
  </View>
95
101
  {props.description ? (
96
102
  <Text size="xs" color="muted">
@@ -33,8 +33,13 @@ export function CardSelectItem(props: CardSelectItemProps) {
33
33
  aria-pressed={selected}
34
34
  onPress={onPress}
35
35
  style={(state: PressableHighlightState) => {
36
- const active = selected || state.hovered || state.pressed || state.focusVisible;
37
- return [styles.container, active && styles.ring, style];
36
+ // Selection is a GROUND; hover/press/focus are a ring. They used to
37
+ // share one style, which made the card you had CHOSEN pixel-identical to
38
+ // the card under the pointer — the same defect 43.5.0 fixed one release
39
+ // earlier for `Table`'s selected row, and the reason the kit's own
40
+ // `tpl_lookup` routed around this component with a hand-rolled ground.
41
+ const ringed = state.hovered || state.pressed || state.focusVisible;
42
+ return [styles.container, selected && styles.selected, ringed && styles.ring, style];
38
43
  }}
39
44
  >
40
45
  {children}
@@ -56,4 +61,12 @@ const styles = StyleSheet.create({
56
61
  ring: {
57
62
  ...({ boxShadow: FOCUS_RING } as ViewStyle),
58
63
  },
64
+ // The accent wash + accent edge is the same "you are here" the selected table
65
+ // row and the active chip carry, so one signal means one thing product-wide.
66
+ // A ground rather than a heavier outline: among white cards a 1px border
67
+ // change is noise, and it vanishes entirely under the hover ring.
68
+ selected: {
69
+ backgroundColor: colors.accent_wash,
70
+ borderColor: colors.accent,
71
+ },
59
72
  });
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}
@@ -38,12 +38,26 @@ export const CONTROL_CONTENT_HEIGHT = 28;
38
38
  export const CONTROL_PADDING_V = (CONTROL_HEIGHT - CONTROL_CONTENT_HEIGHT) / 2 - 1;
39
39
 
40
40
  /** The narrowest a control may be and still be usable — below it, editors get
41
- * crushed and option text has nowhere to go. It is the threshold at which a
42
- * `DetailTable` gives up its side-by-side columns and stacks, and the floor
43
- * under a field popover's width, so "too narrow to use" means one thing across
44
- * the kit rather than a number re-picked per component. */
41
+ * crushed and option text has nowhere to go. It is the floor under a field
42
+ * popover's width, so "too narrow to use" means one thing across the kit rather
43
+ * than a number re-picked per component. */
45
44
  export const MIN_CONTROL_WIDTH = 160;
46
45
 
46
+ /**
47
+ * The narrowest a record's VALUE column may be before `DetailTable` stops
48
+ * putting label and value side by side and stacks them instead.
49
+ *
50
+ * Deliberately larger than {@link MIN_CONTROL_WIDTH}, because the two answer
51
+ * different questions. A control's floor asks "can this still be operated"; a
52
+ * value column's asks "can this still be READ" — and a value column holds
53
+ * company names, addresses and reference codes, not just controls. Sharing the
54
+ * control's 160 put the stack threshold low enough that a 375px screen kept its
55
+ * two columns and handed the value ~170px: a name needing 221 got 104, a tax
56
+ * code needing 114 got 53. Both halves measured "fine" and the row was
57
+ * unreadable.
58
+ */
59
+ export const MIN_VALUE_WIDTH = 200;
60
+
47
61
  /** The system control radius — every interactive control surface (Button, the
48
62
  * inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
49
63
  * track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
package/src/data_grid.tsx CHANGED
@@ -5,6 +5,7 @@ import { FocusRingPressable } from "./focus_ring_pressable";
5
5
  import { Icon } from "./icon";
6
6
  import { Text } from "./text";
7
7
  import { type SortState, type SortHeaderLabels } from "./sort_header";
8
+ import { FLEX_MIN_WIDTH } from "./table_fit";
8
9
 
9
10
  export interface DataGridColumn<T> {
10
11
  key: string;
@@ -178,7 +179,14 @@ export const gridRowStyle: ViewStyle = {
178
179
 
179
180
  const styles = StyleSheet.create({
180
181
  row: gridRowStyle,
181
- flexCol: { flex: 1, minWidth: 0 },
182
+ // A FLOOR, not `minWidth: 0`. This grid sheds no columns — every column is a
183
+ // measure you came to compare, which is what separates it from `Table`'s
184
+ // register of objects — so the fixed columns hold their widths and the
185
+ // flexible identity column absorbed the entire squeeze. At 0 it absorbed it
186
+ // all the way to nothing: a narrow container rendered rows of unlabelled
187
+ // money, silently, with no overflow to notice. Past the floor the grid
188
+ // overflows and the caller scrolls it; see catalog.md § DataGrid.
189
+ flexCol: { flex: 1, minWidth: FLEX_MIN_WIDTH },
182
190
  head: {
183
191
  minHeight: 30,
184
192
  borderBottomWidth: 1,