@lotics/ui 46.2.0 → 46.8.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.
Files changed (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. package/src/line_chart_labels.ts +0 -32
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
19
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; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), and where a commit sits. |
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. GATES that outrank every treatment question, then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
21
+ | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
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. |
24
24
 
@@ -75,6 +75,30 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
75
75
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
76
76
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
77
77
  → [composition.md §"Color discipline"](./docs/composition.md).
78
+ - **An overlay paints in the order it was OPENED, and a scroller that swaps content opens the new
79
+ content at the top.** Compose overlays wherever you like — a `Dialog` written beside the
80
+ `Drawer` whose row opens it works, both ways round, and a `Dialog` opened from an open `Popover`
81
+ paints over its panel. An overlay of your OWN gets that for free by rendering its react-native
82
+ `Modal` **only while open** (`{open && <Modal visible …>}`); mounted while closed it takes its
83
+ body-level slot on the app's first paint and is then covered by anything opened later —
84
+ rendering and announcing perfectly, dead to every press. The z-index rungs beside the overlays
85
+ are published in `overlay_layer`, so never hand-pick a number to clear a Lotics overlay.
86
+ `DrawerScrollArea` / `DialogScrollArea` / `ModalBody` take a `scrollKey` and the routed
87
+ `Popover` takes the same seam off its route, so a swapped-in record opens at the top and the
88
+ reader returns to their place on the way back.
89
+ → [composition.md §"Canvas & content column"](./docs/composition.md)
90
+ → [catalog.md §"Overlays & navigation"](./docs/catalog.md)
91
+ - **ONE VALUE, ONE RENDERING, inside one card — the chart draws the entity its table draws.**
92
+ A value's filter renders it the way its cell does, and the same rule reaches the chart beside
93
+ the table: `StackedBarRow` and `BarChartItem` take a `leading` slot for the entity's own mark.
94
+ A LEGEND row is the exception — its subject is a category, and the swatch is that identity.
95
+ → [composition.md §"A register that TRIAGES"](./docs/composition.md).
96
+ - **A row says which side HOLDS; `numberOfLines` handles the side that gives way.** The clamp
97
+ brings its own `minWidth: 0` + `flexShrink: 1` (a clamp that cannot shrink cannot clamp), so
98
+ what a call site still has to state is `flexShrink: 0` on the figure or control that must stay
99
+ whole. Same law on a categorical axis: the LABELS thin to fit, the bars never do.
100
+ → [composition.md §"Typography"](./docs/composition.md),
101
+ [catalog.md §"Numbers & charts"](./docs/catalog.md).
78
102
  - **Hand-typed type is off-system — and it always lands too small.** Every run of language is
79
103
  `<Text size= weight= color=>` on the fixed rungs. **Grep the diff: `fontSize:` / `lineHeight:` /
80
104
  `letterSpacing:` outside `src/` is a bug.** → [composition.md §"Typography"](./docs/composition.md).
@@ -90,6 +114,19 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
90
114
  (the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
91
115
  button. `Table`/`TableRow` already does this.
92
116
  → [composition.md §"Row actions are always-visible siblings"](./docs/composition.md).
117
+ - **Piles you MOVE records between are a `Board`; bands of rows are not.** `Board` +
118
+ `BoardColumn` + `BoardCard` (`@lotics/ui/board`) — columns are one field's values, and a
119
+ card's `moves` drive a KEYBOARD menu first, with pointer drag layered on the same list.
120
+ `tpl_task_board` is a `DataGrid` of ROWS despite its name; a view built on it and labelled
121
+ Kanban is mislabelled.
122
+ → [catalog.md §"Records in PILES you move them between"](./docs/catalog.md),
123
+ [templates.md §"`Board` vs `tpl_task_board`"](./docs/templates.md).
124
+ - **Money you are PRICING is `ChargeLines`; money you are READING is `Ledger`.** A charge is ONE
125
+ line — `quantity × unitPrice` reading across it and ENDING at the amount, never a derived figure
126
+ hung underneath — and the band's total lands on that same amount column. Below its fork width
127
+ the row takes two lines and the arithmetic RIGHT-ALIGNS to the band, so the amounts keep the
128
+ total's edge. → [catalog.md §"Money you are PRICING"](./docs/catalog.md),
129
+ [templates.md §"`tpl_money`"](./docs/templates.md).
93
130
  - **A fact every row needs is a COLUMN; a fact one row needs expands in place.** `TableRow`'s
94
131
  `detail` + `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
95
132
  → [composition.md §"No dead rows"](./docs/composition.md),
package/MIGRATION.md CHANGED
@@ -4,6 +4,93 @@ 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
+ ## 46.3.0
8
+
9
+ **A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
10
+ A clamp that cannot shrink cannot clamp: on web a clamped `Text` is `white-space: nowrap`, so
11
+ `min-width: auto` floors it at the WHOLE string; on native a `Text` is `flexShrink: 0`. Either way
12
+ two clamped values on one row laid out at intrinsic width and ran off the frame. Both declarations
13
+ now come with the clamp, and both are no-ops until a row actually overflows.
14
+
15
+ Nothing's API changed, but **a layout default moved under every clamped `Text` in your app** — this
16
+ is the one 46.3.0 change to re-check by looking. Two shapes are worth a pass:
17
+
18
+ - A flex ROW where a clamped `Text` was the side that HELD and an unclamped sibling gave way. Both
19
+ can shrink now, so the wrong one may truncate.
20
+ - A flex COLUMN with a bounded height (a fixed-height card, a `maxHeight` panel). `flexShrink` acts
21
+ on the MAIN axis, so a clamped `Text` that used to hold its height can now be compressed.
22
+
23
+ The pair sits BEFORE your own `style`, so the site that must stay whole says so:
24
+
25
+ ```diff
26
+ -<Text numberOfLines={1}>{amount}</Text>
27
+ +<Text numberOfLines={1} style={{ flexShrink: 0 }}>{amount}</Text>
28
+ ```
29
+
30
+ That is how a figure keeps winning over its label. `minWidth` is overridden the same way
31
+ (`style={{ minWidth: "auto" }}`) on the rare surface that wants the old min-content floor back.
32
+
33
+ **An overlay of your OWN must render its react-native `Modal` only while it is OPEN.**
34
+ `Dialog`, `Drawer`, `Modal` and `FileGalleryModal` now do — nothing in their API changed — and a
35
+ hand-rolled overlay outside the kit has to do the same, or it loses to them:
36
+
37
+ ```diff
38
+ -<Modal visible={open} onRequestClose={close} transparent>
39
+ - …
40
+ -</Modal>
41
+ +{open && (
42
+ + <Modal visible onRequestClose={close} transparent>
43
+ + …
44
+ + </Modal>
45
+ +)}
46
+ ```
47
+
48
+ react-native-web appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders
49
+ it, so an overlay mounted while closed takes its slot on the app's first paint and is then covered
50
+ by any overlay opened later — while rendering, reading and announcing perfectly, with only its
51
+ presses dead. Nothing is lost by the change: react-native-web renders a closed `Modal`'s children
52
+ as `null` already, so only the empty portal div was being held. A takeover with
53
+ `animationType="fade"` keeps its fade IN and now closes immediately, as `FileGalleryModal` always
54
+ has; state that must survive a close belongs outside the `Modal` element either way.
55
+
56
+ **The overlay z-index rungs are now published constants.** The numbers are unchanged for `Tooltip`
57
+ and `Alert` (`10000`) and for a toast (`10001`); **the skip link moved `10001` → `10002`**, off the
58
+ rung it was sharing with a toast. Read the rung instead of naming a number:
59
+
60
+ ```diff
61
+ -import { View } from "react-native";
62
+ +import { NOTIFICATION_Z } from "@lotics/ui/overlay_layer";
63
+
64
+ - zIndex: 10001,
65
+ + zIndex: NOTIFICATION_Z,
66
+ ```
67
+
68
+ The full table is in `docs/catalog.md` § Overlays & navigation: `OVERLAY_Z` (every overlay, and
69
+ `Popover`), `OVERLAY_Z_ABOVE` (tooltip/alert), `NOTIFICATION_Z` (a toast), `SKIP_LINK_Z`.
70
+
71
+ **`PageContent`'s title band is now a `PageHeader`**, so the two stop drifting. `title`,
72
+ `titleRight` and `description` are unchanged, and `PageHeader`'s `title` became optional — but the
73
+ band's rhythm is now `PageHeader`'s everywhere: a description sits **8px** under the title (it was
74
+ flush in `PageHeader`) and the band clears **16px** before the content (it was 24 in
75
+ `PageContent`). Nothing to change; worth one look at a page shell you care about.
76
+
77
+ **`useChangeSet`'s per-proposal default is a MAP, not a predicate.** `initial: "accepted"` /
78
+ `"pending"` are unchanged.
79
+
80
+ ```diff
81
+ -useChangeSet(ids, { initial: (id) => (overwrites.has(id) ? "rejected" : "accepted") })
82
+ +const initial = useMemo(
83
+ + () => new Map(rows.filter((r) => r.overwrites).map((r) => [r.id, "rejected" as const])),
84
+ + [rows],
85
+ +);
86
+ +useChangeSet(ids, { initial })
87
+ ```
88
+
89
+ An id the map does not name arrives `accepted`, as before. A value rather than a function is what
90
+ lets the default be an ordinary dependency: `status` and the group arrays derive from it together,
91
+ so `status` changes identity whenever a decision does — which a screen that memoizes its rows on
92
+ `review.status` needs, and which a predicate held out of the dependency lists could not give.
93
+
7
94
  ## 46.0.0
8
95
 
9
96
  **`AgentRun` / `AgentProgress` drop `stepsLabel`, and the `agentRun` locale slice drops `steps`.**
@@ -379,6 +379,17 @@ alignment and rhythm.
379
379
  `keptCount`/`total`/`settled`. `initial` defaults to **`accepted`** so an operator drops
380
380
  exceptions instead of approving eight identical lines; pass `pending` when each change
381
381
  genuinely deserves its own verdict, and gate the commit on `settled`.
382
+ - **A set that mixes KINDS defaults per kind: `initial` also takes a `Map<id, decision>`.** A
383
+ review usually holds both — filling a blank should arrive `accepted`, overwriting a value a
384
+ human already set should arrive `rejected`, so the destructive half is opt-in — and one
385
+ global default puts one of the two on the wrong footing. An id the map does not name arrives
386
+ `accepted`. Build it with a `useMemo` over the proposals your screen already holds, which is
387
+ what keeps the hook ignorant of what a proposal IS; a VALUE (rather than a predicate written
388
+ inline) is what lets `status` and the group arrays derive from it together, so `status`
389
+ changes identity the moment a decision moves and a screen memoizing its rows on it re-runs.
390
+ Deciding those rows by calling `reject()` from the result handler instead writes overrides
391
+ nobody made, and `undo` then hands the row back **accepted** — the destructive change
392
+ silently in, from the control that means "undo".
382
393
 
383
394
  ### The shapes, and what each one is
384
395
 
package/docs/catalog.md CHANGED
@@ -151,8 +151,8 @@ reader must read WHOLE rather than recognise at a glance: a payment term, an add
151
151
  Above 1 the resting box RESERVES that many lines — the full editor height, not the height this
152
152
  particular value needs — so clicking in moves neither the field nor anything below it; Enter
153
153
  then inserts a newline and the value commits on blur.
154
- **`autoGrow`** makes that budget a MINIMUM and fits the value instead see
155
- [data_entry.md §Open prose grows](./data_entry.md). **`link`** marks a URL value with
154
+ **`autoGrow`** makes that budget a MINIMUM and fits the value instead, and a NOTE field always
155
+ takes it — see [data_entry.md §Open prose grows](./data_entry.md). **`link`** marks a URL value with
156
156
  the navigation ink as a TREATMENT on the same single element — never a swapped-in
157
157
  `TextLink`, which would reintroduce the sub-pixel jump this field stays one
158
158
  `<input>` to avoid; pressing still edits, so pair it with an `Open` `InlineButton`
@@ -182,6 +182,33 @@ field rows lives in
182
182
  `DetailTable` + `DetailRow`; the record's identity band is `RecordSummary`; its money
183
183
  summary is `Ledger`.
184
184
 
185
+ ### Money you are PRICING — the charge band
186
+
187
+ `ChargeLines` + `ChargeLine` (`@lotics/ui/charge_lines`) is the WRITE side of a record's money:
188
+ what it charges, priced per line, closed by its own total. `Ledger` is the READ side of the same
189
+ subject and the two are not interchangeable — a ledger has nothing to type into, and this has no
190
+ notion of adjustments or of three sides.
191
+
192
+ - **A charge is ONE line.** The arithmetic reads across it — `quantity × unitPrice`, ending at the
193
+ amount. Hanging the derived figure on a second line doubles every row's height and leaves the
194
+ answer in no column, so the total below closes nothing.
195
+ - **One money column.** Quantity, unit price and amount all right-align and the total lands on the
196
+ amount column, so the band adds up by eye. The typed figures right-align through
197
+ `InlineNumberInput`'s `align`; without it a typed price and a derived one drift apart, which is
198
+ probe 6 in [reviewing.md](./reviewing.md).
199
+ - **The total is the sum of what is on screen.** Never a figure computed off a different array: a
200
+ total that is not the sum of something visible is the one figure a reader cannot check.
201
+ - **The row's verb sits in a fixed slot**, so the money column does not shift between a row that
202
+ can be removed and one that cannot. `locked` turns the editors into values for a settled charge.
203
+ - **Below its fork width** the name takes its own line and the arithmetic sits under it — a
204
+ two-line row ON PURPOSE, measured on the band's own container because it lives in drawers as
205
+ often as on pages. The arithmetic RIGHT-ALIGNS to the band there: laid out from the left it
206
+ would land the amounts wherever the fixed slot widths happen to end, which coincides with the
207
+ closing total only when the container is exactly as wide as the row. The row's money verb moves
208
+ up to the name line in the fork, because the arithmetic line has no width to reserve for it.
209
+ - **The field names are locale-wired** — `LoticsLocale.chargeLines` gives the quantity, unit-price
210
+ and amount editors their screen-reader names, each qualified by the charge it belongs to.
211
+
185
212
  ### Tasks, to-dos, phased work — ONE compound, two axes
186
213
 
187
214
  Anything a reader marks is `checklist` — `Checklist` + `ChecklistGroup` + `ChecklistItem`. Two
@@ -217,7 +244,9 @@ same record, so it follows automatically; for a surface whose SUBJECT is tasks s
217
244
 
218
245
  ### Tabular data — pick by SCALE + intent
219
246
 
220
- Two columnar shapes, and the choice is about data size:
247
+ Two columnar shapes, and the choice is about data size. (A third shape divides the same records
248
+ into **piles you move them between** — that is the board, below, and it is picked by INTENT, not
249
+ by size.)
221
250
 
222
251
  - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
223
252
  headers via `SortHeader`; paired with `Pagination`) + rows that either open a `Drawer` to
@@ -253,6 +282,56 @@ Two columnar shapes, and the choice is about data size:
253
282
  grouping + pagination/infinite don't compose; use the register instead. Worked example:
254
283
  [`tpl_task_board`](../examples/tpl_task_board.tsx) (a `CheckCircle` leading).
255
284
 
285
+ ### Records in PILES you move them between — the column board
286
+
287
+ `Board` + `BoardColumn` + `BoardCard` (`@lotics/ui/board`). Columns are the values of ONE
288
+ field; cards are records; the act the shape exists for is sending a card to another column.
289
+ Reach for it when the reader ADVANCES work and the pile sizes are themselves the report — not
290
+ when they compare a row's measures (`DataGrid`) or browse thousands (`Table`).
291
+
292
+ Worked example: [`tpl_board`](../examples/tpl_board.tsx).
293
+
294
+ - **Order WITHIN a column is deliberately not offered.** The board moves a card BETWEEN piles;
295
+ it does not let you drag one above another in the same pile. Ordering that survives a reload
296
+ needs a persisted rank field on the record, and without one the reader arranges a pile, leaves,
297
+ and finds it rearranged — which is worse than never offering it. A pile is sorted by a column
298
+ the data already carries (a deadline, a priority), stated once. An earlier implementation shipped
299
+ an insert-at-index affordance with no rank behind it; it is gone.
300
+
301
+ - **A column states its identity and its SIZE separately.** `heading` is a node — the component
302
+ the value's data role owns (`OptionBadge` for a select, `MemberChip` for a person) — and
303
+ `count` is a PROP. Formatted into the heading the number becomes part of the column's NAME.
304
+ - **The move is keyboard-first.** A card's `moves` (`{ key, label }[]`) name its destinations,
305
+ and the card renders a focusable control whose menu items are those labels. Pointer drag is
306
+ layered on the SAME list: the grip drags, the drop resolves to the column under the pointer,
307
+ and a destination the menu would refuse is refused there too. Omit `moves` and the card
308
+ carries no move control — the honest rendering of a card that cannot go anywhere.
309
+ - **The board owns its horizontal scroll**, so the page body never scrolls sideways and the
310
+ shape survives 375. **Columns grow and the page scrolls** — a bounded board height is a number
311
+ nobody can derive, which is how `height: 560` gets hand-picked into an app.
312
+ - **A card's controls sit in the CARD's top-right corner, and they are always visible.** Pinned
313
+ to the card rather than carried by the title row, because a title that wraps to two lines
314
+ otherwise pushes them down and a control that moves with the length of the text beside it is one
315
+ the reader hunts for on every card. Always visible rather than revealed on hover: hover-reveal is
316
+ the exception a DENSE register earns, where forty controls at rest drown the rows — a board is
317
+ already sparse, and hiding the one act the shape exists for leaves a pointer user with a card
318
+ that looks inert. (Revealing on focus only is the same bug wearing a keyboard: the pointer, which
319
+ is how most people drive a board, never triggers it.)
320
+ - **The card face states the record; the MOVE MENU states the destinations.** A badge on the card
321
+ repeating the column it already sits in prints one fact twice. The same badge in the menu is new
322
+ information — it names where the card would GO, in that column's own vocabulary — so the menu
323
+ takes each destination's heading node and cannot drift from the pile it points at.
324
+ - **A card darkens under the pointer and its geometry does not move.** The wash is the card's own,
325
+ one neutral step; a border appearing on hover adds an edge to a surface already full of them.
326
+ - **A drop must not open the record.** The browser ends a drag with a press on the row the grip
327
+ sits in, and that row is a door — so without a guard every drag dumps the reader into a detail
328
+ surface they never asked for. Suppress the press for the tick after a drop. The case that
329
+ reproduces it is a SHORT drag, where the release lands back inside the card it started in.
330
+ - `BOARD_COLUMN_WIDTH` is the kit's column width. Do not re-pick it per app.
331
+ - Not the same thing as [`tpl_task_board`](../examples/tpl_task_board.tsx), whose name says
332
+ columns and whose body is a `DataGrid` of ROWS — see
333
+ [templates.md §"`Board` vs `tpl_task_board`"](./templates.md).
334
+
256
335
  ### Numbers & charts
257
336
 
258
337
  `KPIStrip` (the dashboard stat band), `SummaryLine` (the light inline register/list summary
@@ -443,6 +522,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
443
522
  numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
444
523
  box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
445
524
  its annotation; see composition.md, which also covers the `gap` that goes with it.
525
+ **`numberOfLines`** clamps, and brings the `minWidth: 0` + `flexShrink: 1` that makes clamping
526
+ possible on a flex row — a clamp that cannot shrink cannot clamp. Both are no-ops until the row
527
+ overflows, and a `style` on the call site still wins, so a value that must never give way says
528
+ `flexShrink: 0` and keeps holding over its own label.
446
529
  - **`eyebrow`** — `Eyebrow`: the small quiet label above or beside a VALUE (`xs` muted medium) —
447
530
  a metric caption, a field name in a cell, an artifact tag. Takes `color` (for a VERDICT word
448
531
  like "Mismatch") and `align`, and deliberately takes no `size`, `weight` or `transform`:
@@ -702,16 +785,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
702
785
  useSeparator` inserts and what menus/popovers put between option groups.
703
786
  - **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
704
787
  `CONTAINER_SIZES`).
705
- - **`page_header`** — `PageHeader`: the page's title band. `actions` puts page-level CTAs on
788
+ - **`page_header`** — `PageHeader`: the page's title band, and the ONE place that band is
789
+ defined — `PageContent`'s `title`/`titleRight`/`description` render one of these rather than a
790
+ second copy of the row. `actions` puts page-level CTAs on
706
791
  the title row (right-aligned) and `trailing` puts a control immediately AFTER the title;
707
792
  `left`/`right` form a separate nav row above. Split `trailing` from `actions` by what the
708
793
  control acts on: `actions` do something to the page's CONTENT (create, sort, export),
709
794
  `trailing` changes what is AROUND it (a side-panel toggle, a view switch). A panel toggle
710
795
  filed under `actions` reads as a peer of "create one of these". Under a title too long for
711
796
  the row the TITLE gives way and wraps while `trailing`
712
- and `actions` keep their width.
797
+ and `actions` keep their width. **The band owns the space under itself** — 16px, whether it
798
+ is rendered on a hand-rolled screen or by `PageContent`; a shell adds nothing after it, or
799
+ the same band closes on two different rhythms depending on which shell a screen picked.
713
800
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
714
- region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
801
+ region — a centred column with optional `title`/`titleRight`/`description` (which render a
802
+ `PageHeader`, so the band's row law and rhythm have one home), `header`/`footer`
715
803
  slots and `fullscreen`. **Reach for it before hand-rolling a screen shell.** Its side
716
804
  padding is `pagePad` (`@lotics/ui/spacing`), so a screen that
717
805
  genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
@@ -1145,7 +1233,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1145
1233
  a list too small for its own row.
1146
1234
  Pass `actions` UNCONDITIONALLY and `disabled` the verb when it has nothing to act on: a slot
1147
1235
  that appears once the value is non-empty resizes the field as the user types.
1148
- - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
1236
+ - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units, `align="right"`
1237
+ to put the RESTING figure on its slot's far edge so a typed money column shares one edge —
1238
+ data_entry.md §Billing),
1149
1239
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
1150
1240
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
1151
1241
  — locale field order, separator advances, Alt+ArrowDown floats the calendar; click still
@@ -1293,6 +1383,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1293
1383
  line box, so the band is the same height either way and a loading record's header does not
1294
1384
  move when the number lands. Still ONE number: a node is a stand-in for it, not a licence to
1295
1385
  put a control in the slot.
1386
+ - **`charge_lines`** — `ChargeLines` + `ChargeLine`: the editable money band — what a record
1387
+ CHARGES, priced line by line, closed by its own total. The write side of `Ledger`'s subject,
1388
+ and not interchangeable with it: a ledger has nothing to type into and this has no notion of
1389
+ adjustments. A charge is ONE line — `quantity`/`unitPrice` derive the amount and the amount is
1390
+ never typed; leave both out and the `amount` itself becomes the editor (`amountActions` puts a
1391
+ list price one tap away, `unitPriceActions` a rate). Verbs sit BESIDE the field, never inside
1392
+ it, and the row's own verb (`action`, usually removal) sits in a slot held open on every row —
1393
+ both so the money column cannot move between rows. `extra` carries a second control belonging
1394
+ to the charge (how it was paid); `warning` names a problem on the line that has it; `locked`
1395
+ turns the editors into values for a settled band; `empty` speaks for a record nobody has priced
1396
+ yet. `formatMoney` is the BAND's, so two lines cannot disagree about a currency, and the `total`
1397
+ you pass must be the sum of the lines on screen. Below its fork width the row takes two lines
1398
+ with the arithmetic right-aligned to the band — see § "Money you are PRICING" for the full
1399
+ rules. Worked example: [`tpl_money`](../examples/tpl_money.tsx).
1296
1400
  - **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerBasis` + `LedgerRow` + `LedgerTotal` — the
1297
1401
  record-level money list: every figure on ONE right-aligned tabular column, `peek` turns a row
1298
1402
  into a pressable door floating its particulars in an anchored popover (put links INSIDE the
@@ -1505,6 +1609,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1505
1609
  - **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
1506
1610
  [Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
1507
1611
  a11y via `SortHeaderLabels`.
1612
+ - **`board`** — `Board` + `BoardColumn` + `BoardCard` + `BoardMove` + `BOARD_COLUMN_WIDTH`:
1613
+ the column board — piles you MOVE records between (see
1614
+ [Records in PILES](#records-in-piles-you-move-them-between--the-column-board)). The board
1615
+ owns the horizontal scroll; a column takes a `heading` node, a `columnKey` and a `count`
1616
+ prop; a card takes `moves` + `onMove`, which drive the keyboard menu AND the pointer drop.
1617
+ Strings resolve through the `board` locale slice (`open` / `move` / `moveTo` / `empty`),
1618
+ overridable per card with `openLabel` / `moveLabel` and per column with `emptyMessage`.
1508
1619
  - **`pagination`** — `Pagination` (+ `PaginationLabels`): the register's pager — a range and two
1509
1620
  arrows, no page index. ONE per register, ABOVE the rows on the `SummaryLine` row; its range is
1510
1621
  the register's count (pair with `Table` `counted`), and `onPageChange` owes a scroll reset the
@@ -1586,15 +1697,56 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1586
1697
 
1587
1698
  ### Overlays & navigation
1588
1699
 
1700
+ **Every overlay paints in the order it was OPENED**, so mount them wherever composition wants — a
1701
+ `Dialog` written beside the `Drawer` whose row opens it is the ordinary shape and it works; a
1702
+ drawer opened from a dialog covers the dialog, which is the same rule the other way round. There
1703
+ is no layer prop and nothing at the call site has to know the tree order. It covers every
1704
+ body-level surface the kit has, `Popover` included: a `Dialog` or a file-preview `Modal` opened
1705
+ from an open popover paints OVER the panel that opened it. `Alert` and `Tooltip` stay above every
1706
+ overlay at any depth.
1707
+
1708
+ **The one rule an overlay of your OWN has to follow: render the react-native `Modal` element only
1709
+ while it is open** (`{open && <Modal visible …>}`, or an early `return null`). react-native-web
1710
+ appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders it, so an overlay
1711
+ mounted while closed has already taken its slot before anything opened — and once two overlays tie
1712
+ at the same z-index, that slot is what decides. The symptom is nasty precisely because it is not
1713
+ visual: the covered surface renders, reads and announces perfectly, and only its presses do
1714
+ nothing. Nothing is lost by not mounting it — react-native-web renders a closed `Modal`'s children
1715
+ as `null` anyway, so only the empty portal div was ever being held, and anything that must survive
1716
+ a close (a router, a context, form state) belongs outside the `Modal` element regardless.
1717
+
1718
+ **The rungs beside the overlays are a published contract** (`overlay_layer`), because a surface
1719
+ OUTSIDE the kit that has to clear a Lotics overlay cannot do it with a hand-picked literal:
1720
+
1721
+ | z-index | what sits there |
1722
+ |---|---|
1723
+ | `9999` | every overlay, and `Popover` — `OVERLAY_Z` |
1724
+ | `10000` | `Tooltip`, `Alert` — `OVERLAY_Z_ABOVE` |
1725
+ | `10001` | a transient notification / toast — `NOTIFICATION_Z` |
1726
+ | `10002` | the skip link — `SKIP_LINK_Z` |
1727
+
1728
+ Overlays all share one rung on purpose: DOM order settles the tie and DOM order is open order.
1729
+ `Popover` is on it too — it is no `Modal`, but it portals into the nearest `PortalHost`, so one
1730
+ opened inside an overlay is already inside that overlay's stacking context and a page-level one is
1731
+ a child of the app root, which every overlay's body-level box follows.
1732
+
1589
1733
  - **`portal`** — `PortalHost` + `Portal`: the floating-content mount point; the app root
1590
1734
  needs ONE `PortalHost` or `Dialog`/`Popover`/`Tooltip`/`Alert`/`OptionList` cannot render.
1591
1735
  - **`dialog`** — `Dialog` (+ `DialogHeader`/`DialogHeaderTitle`/`DialogFooter`, `useDialog`,
1592
1736
  `useDialogNavigation`): the centered card over a scrim; BAKES a screen router in
1593
- (`<Dialog><Screen route="">…` — see `screen_router`).
1737
+ (`<Dialog><Screen route="">…` — see `screen_router`). `DialogScrollArea` takes the same
1738
+ **`scrollKey`** seam as `DrawerScrollArea` for a pane that swaps its body in place; a dialog
1739
+ navigating between `Screen`s does not need it, because a stacked screen stays mounted and keeps
1740
+ its own offset.
1594
1741
  - **`drawer`** — `Drawer` + `DrawerScrollArea` + `DrawerFooter`: the docked side panel with scrim; the
1595
1742
  register row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
1596
1743
  exceptional panel; full-width on small screens. `DrawerScrollArea` is the guttered content
1597
- region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. It always
1744
+ region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. Give it a
1745
+ **`scrollKey`** (the open content's identity — `scrollKey={openChild?.id}`, where the undefined
1746
+ leg names the root content rather than opting out) on a drawer that SWAPS its body in place, so
1747
+ the new content opens at the top and the previous one's offset returns on the way back — the seam
1748
+ `DialogScrollArea` and `ModalBody` take too, and the one thing a React `key` on the scroller
1749
+ cannot buy, since that resets the parent's place along with the child's. It always
1598
1750
  scrolls: a padded box around a `flex:1` scroller insets that scroller's viewport, ending the
1599
1751
  list short of the panel with the last row clipped. Two bodies skip it and take the `Drawer`'s
1600
1752
  bare slot: a FULL-BLEED one (a band spanning the panel, a record screen with its own gutters),
@@ -1605,7 +1757,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1605
1757
  edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
1606
1758
  (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
1607
1759
  to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader
1608
- (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned
1760
+ (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody (which takes the
1761
+ **`scrollKey`** seam — a wizard stepping between steps swaps its body in place), a pinned
1609
1762
  ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a
1610
1763
  focused capture / multi-step wizard / a console the user steps INTO; pick Dialog when the
1611
1764
  surface is a card the user can see context around.
@@ -1631,11 +1784,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1631
1784
  The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1632
1785
  amount, so a scrolling body reaches the panel's real edges while the text stays on the
1633
1786
  same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
1634
- (which own their scroll) are untouched.
1787
+ (which own their scroll) are untouched. It also carries the **scroll seam** every scroller
1788
+ that swaps its body in place takes — keyed on the routed popover's current route, with no prop
1789
+ at call site, and inert in a popover that has no sub-screens.
1635
1790
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
1636
1791
  built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
1637
1792
  `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
1638
- root and screens render conditionally (unmounted when inactive no scroll preservation),
1793
+ root and screens render conditionally (unmounted when inactive, so a screen's own state does
1794
+ not survive the round trip — the panel's SCROLL does: the screens share `PopoverContent`'s one
1795
+ scroller, which takes the ROUTE as its seam key, so a sub-screen opens at the top with its
1796
+ `PopoverNavHeader` in view and the root list's offset returns on the way back),
1639
1797
  `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack`
1640
1798
  (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover; route PATTERNS,
1641
1799
  `params`, and stacked-alive screens are `screen_router`'s job.
@@ -1661,6 +1819,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1661
1819
  content) — it is what `SectionHeadingTitle.info`/`CardHeaderTitle.info` render.
1662
1820
  - **`alert`** — `Alert` (+ `AlertButton`/`AlertOptions`): the blocking confirm —
1663
1821
  `Alert.alert(title, message, [{cancel}, {destructive}])`.
1822
+ - **`overlay_layer`** — `OVERLAY_Z` / `OVERLAY_Z_ABOVE` / `NOTIFICATION_Z` / `SKIP_LINK_Z`: the
1823
+ ONE paint-order table for everything that reaches `document.body` (the rungs are in the table
1824
+ above), and the file that states why open order works. Constants only — there is no layer to
1825
+ render and nothing to claim; an overlay of your own gets its place by mounting its react-native
1826
+ `Modal` only while it is open. Never write one of these numbers as a literal.
1664
1827
  - **`overlay_scope`** — `isOverlayScopeActive` / `pushOverlayScope` / `useOverlayScope`: the
1665
1828
  module-level open-overlay counter every overlay primitive reports into; the host's
1666
1829
  shortcut registry reads it synchronously to floor page-level shortcuts while any overlay
@@ -1715,11 +1878,24 @@ component rather than showing it at zero.
1715
1878
  it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
1716
1879
  register wants. Reach for a real chart the moment someone needs to read a value off it.
1717
1880
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1718
- `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1719
- as the track fits and thins the rest, anchored on the LAST point the newest reading is the
1720
- one a reader looks up, and anchoring there is what keeps the spacing uniform. The first
1721
- point is labelled only when it clears the same distance, so a series whose length does not
1722
- divide evenly drops its opening label rather than crowding the one beside it.
1881
+ `PieChart`: the canonical SVG chart set (no recharts). **A categorical axis prints as many
1882
+ labels as the track fits and thins the rest**`LineChart` and `BarChart`'s vertical
1883
+ orientation both, because it is the axis's rule and not one chart's. It is anchored on the LAST
1884
+ position: the newest reading is the one a reader looks up, and anchoring there is what keeps the
1885
+ spacing uniform; the first position is labelled only when it clears the same distance, so a
1886
+ series whose length does not divide evenly drops its opening label rather than crowding the one
1887
+ beside it. **The BARS never thin** — how many bars a card carries is a question about the data,
1888
+ never about how wide a label happens to be, and an unlabelled bar keeps its slot so the row
1889
+ above stays in line. **What thins is the whole annotation, label AND value together**, because a
1890
+ bar's name and its figure are one reading: a number under an unnamed bar is a quantity of nothing
1891
+ a reader can name, and the surviving annotation is widened across the room its dropped neighbours
1892
+ vacated — room the figure needs as much as the name does. So a DENSE vertical `BarChart` (more
1893
+ bars than `track ÷ 50px`: a 30-day daily series in a 600px card keeps roughly ten) shows the
1894
+ figures only for the bars it labels. When every bar's number has to be readable, that is a
1895
+ `Table` or the horizontal orientation, which gives each entity its own row and its own value
1896
+ column. `BarChart`'s horizontal orientation is one row per entity: **`labelWidth`**
1897
+ sizes the fixed label column (default 80, which fits a date and not a company name), and each
1898
+ item's **`leading`** slot carries that entity's own mark ahead of its name.
1723
1899
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1724
1900
  plain sm tabular count beside it.
1725
1901
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -1741,7 +1917,10 @@ component rather than showing it at zero.
1741
1917
  cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
1742
1918
  drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
1743
1919
  a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
1744
- never the only channel. Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1920
+ never the only channel. **`leading`** is the entity's own mark (`BrandMark`, `Avatar`, a status
1921
+ dot), centred on the label's line box — a row's segment colours belong to the MEASURE, so unlike
1922
+ a legend row this one carries no identity until you give it one, and a chart beside a table over
1923
+ the same entities has to draw them the same way (composition.md § the register's own craft). Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1745
1924
  (one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
1746
1925
  compare.
1747
1926
  - **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
@@ -1780,9 +1959,15 @@ component rather than showing it at zero.
1780
1959
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
1781
1960
  `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
1782
1961
  RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column — and row
1783
- labels carry default ink, because a row's label is its identity, not chrome. `MATRIX_COL_GAP` is
1962
+ labels carry default ink, because a row's label is its identity, not chrome. **The totals column
1963
+ takes one flex share like a data column**, because a total is the longest figure in its row: on a
1964
+ fixed width it is the FIRST thing to clip, and a clamped figure is whole in the DOM, so nothing
1965
+ but a rendered screen sees it. `MATRIX_COL_GAP` is
1784
1966
  exported for a caller budgeting
1785
- its own column widths.
1967
+ its own column widths. **A money matrix states its unit once** — in the card's
1968
+ `CardHeaderMeta`, cells bare (`formatCompactNumber`, not `formatMoney`), per
1969
+ [composition.md](./composition.md) §"A column states its unit ONCE": one currency glyph per cell
1970
+ is the same word fifty times and it is what pushes the totals column past its share.
1786
1971
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1787
1972
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1788
1973
  KPI can be driven off the same numbers the grid shows.
@@ -1993,6 +2178,10 @@ component rather than showing it at zero.
1993
2178
  NODE** as well as a string, because a second line can itself be carrying a change (a file
1994
2179
  being RECLASSIFIED is the same bytes filed differently — a `DiffValue` on the property); a
1995
2180
  node renders outside the muted single-line `Text`, which would clip it and fight its colour.
2181
+ It also takes the **field annotations** (`description` / `warning` / `error`) — an expected
2182
+ document is a value the reader supplies, so a row whose document FAILS says so on the row, in
2183
+ the same block `DetailRow` renders; without it a fault could only be demoted into a callout
2184
+ about the whole section, which says something different.
1996
2185
  Both `sm` variants share ONE beat (48px min + 6px vertical padding).
1997
2186
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
1998
2187
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
@@ -2205,7 +2394,14 @@ component rather than showing it at zero.
2205
2394
  `ids` is normally a memo over query rows — a new array every render, which is the
2206
2395
  `Maximum update depth exceeded` loop). `initial` defaults to `accepted` so the operator drops
2207
2396
  exceptions instead of approving eight identical lines; pass `pending` when each change deserves
2208
- its own verdict and gate the commit on `settled`.
2397
+ its own verdict and gate the commit on `settled`. **`initial` also takes a
2398
+ `ReadonlyMap<id, decision>`**, for a set that mixes kinds with different safe defaults —
2399
+ filling a blank arrives `accepted`, overwriting a value a human already set arrives `rejected`,
2400
+ so the destructive half is opt-in; an id the map does not name arrives `accepted`. Build it in a
2401
+ `useMemo` over your own rows: it is a VALUE, so `status` and the groups derive from it together
2402
+ and `status` changes identity whenever a decision does — which is what a screen memoizing its
2403
+ rows on `status` needs. Do not reach for `reject()` in the result handler instead: that writes
2404
+ overrides the operator never made, so `undo` hands the row back `accepted`.
2209
2405
  - **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
2210
2406
  (the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
2211
2407
  the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires