@lotics/ui 46.3.0 → 46.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -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
 
@@ -114,6 +114,19 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
114
114
  (the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
115
115
  button. `Table`/`TableRow` already does this.
116
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).
117
130
  - **A fact every row needs is a COLUMN; a fact one row needs expands in place.** `TableRow`'s
118
131
  `detail` + `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
119
132
  → [composition.md §"No dead rows"](./docs/composition.md),
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,36 @@ 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
+ and silences EVERY verb on the band — the row's own and both money verbs. A verb on a figure
204
+ nobody can change is a control with nothing behind it, and a disabled one that merely repeats
205
+ the value beside it prints the same number twice on one line.
206
+ - **Below its fork width** the name takes its own line and the arithmetic sits under it — a
207
+ two-line row ON PURPOSE, measured on the band's own container because it lives in drawers as
208
+ often as on pages. The arithmetic RIGHT-ALIGNS to the band there: laid out from the left it
209
+ would land the amounts wherever the fixed slot widths happen to end, which coincides with the
210
+ closing total only when the container is exactly as wide as the row. The row's money verb moves
211
+ up to the name line in the fork, because the arithmetic line has no width to reserve for it.
212
+ - **The field names are locale-wired** — `LoticsLocale.chargeLines` gives the quantity, unit-price
213
+ and amount editors their screen-reader names, each qualified by the charge it belongs to.
214
+
185
215
  ### Tasks, to-dos, phased work — ONE compound, two axes
186
216
 
187
217
  Anything a reader marks is `checklist` — `Checklist` + `ChecklistGroup` + `ChecklistItem`. Two
@@ -217,7 +247,9 @@ same record, so it follows automatically; for a surface whose SUBJECT is tasks s
217
247
 
218
248
  ### Tabular data — pick by SCALE + intent
219
249
 
220
- Two columnar shapes, and the choice is about data size:
250
+ Two columnar shapes, and the choice is about data size. (A third shape divides the same records
251
+ into **piles you move them between** — that is the board, below, and it is picked by INTENT, not
252
+ by size.)
221
253
 
222
254
  - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
223
255
  headers via `SortHeader`; paired with `Pagination`) + rows that either open a `Drawer` to
@@ -253,6 +285,56 @@ Two columnar shapes, and the choice is about data size:
253
285
  grouping + pagination/infinite don't compose; use the register instead. Worked example:
254
286
  [`tpl_task_board`](../examples/tpl_task_board.tsx) (a `CheckCircle` leading).
255
287
 
288
+ ### Records in PILES you move them between — the column board
289
+
290
+ `Board` + `BoardColumn` + `BoardCard` (`@lotics/ui/board`). Columns are the values of ONE
291
+ field; cards are records; the act the shape exists for is sending a card to another column.
292
+ Reach for it when the reader ADVANCES work and the pile sizes are themselves the report — not
293
+ when they compare a row's measures (`DataGrid`) or browse thousands (`Table`).
294
+
295
+ Worked example: [`tpl_board`](../examples/tpl_board.tsx).
296
+
297
+ - **Order WITHIN a column is deliberately not offered.** The board moves a card BETWEEN piles;
298
+ it does not let you drag one above another in the same pile. Ordering that survives a reload
299
+ needs a persisted rank field on the record, and without one the reader arranges a pile, leaves,
300
+ and finds it rearranged — which is worse than never offering it. A pile is sorted by a column
301
+ the data already carries (a deadline, a priority), stated once. An earlier implementation shipped
302
+ an insert-at-index affordance with no rank behind it; it is gone.
303
+
304
+ - **A column states its identity and its SIZE separately.** `heading` is a node — the component
305
+ the value's data role owns (`OptionBadge` for a select, `MemberChip` for a person) — and
306
+ `count` is a PROP. Formatted into the heading the number becomes part of the column's NAME.
307
+ - **The move is keyboard-first.** A card's `moves` (`{ key, label }[]`) name its destinations,
308
+ and the card renders a focusable control whose menu items are those labels. Pointer drag is
309
+ layered on the SAME list: the grip drags, the drop resolves to the column under the pointer,
310
+ and a destination the menu would refuse is refused there too. Omit `moves` and the card
311
+ carries no move control — the honest rendering of a card that cannot go anywhere.
312
+ - **The board owns its horizontal scroll**, so the page body never scrolls sideways and the
313
+ shape survives 375. **Columns grow and the page scrolls** — a bounded board height is a number
314
+ nobody can derive, which is how `height: 560` gets hand-picked into an app.
315
+ - **A card's controls sit in the CARD's top-right corner, and they are always visible.** Pinned
316
+ to the card rather than carried by the title row, because a title that wraps to two lines
317
+ otherwise pushes them down and a control that moves with the length of the text beside it is one
318
+ the reader hunts for on every card. Always visible rather than revealed on hover: hover-reveal is
319
+ the exception a DENSE register earns, where forty controls at rest drown the rows — a board is
320
+ already sparse, and hiding the one act the shape exists for leaves a pointer user with a card
321
+ that looks inert. (Revealing on focus only is the same bug wearing a keyboard: the pointer, which
322
+ is how most people drive a board, never triggers it.)
323
+ - **The card face states the record; the MOVE MENU states the destinations.** A badge on the card
324
+ repeating the column it already sits in prints one fact twice. The same badge in the menu is new
325
+ information — it names where the card would GO, in that column's own vocabulary — so the menu
326
+ takes each destination's heading node and cannot drift from the pile it points at.
327
+ - **A card darkens under the pointer and its geometry does not move.** The wash is the card's own,
328
+ one neutral step; a border appearing on hover adds an edge to a surface already full of them.
329
+ - **A drop must not open the record.** The browser ends a drag with a press on the row the grip
330
+ sits in, and that row is a door — so without a guard every drag dumps the reader into a detail
331
+ surface they never asked for. Suppress the press for the tick after a drop. The case that
332
+ reproduces it is a SHORT drag, where the release lands back inside the card it started in.
333
+ - `BOARD_COLUMN_WIDTH` is the kit's column width. Do not re-pick it per app.
334
+ - Not the same thing as [`tpl_task_board`](../examples/tpl_task_board.tsx), whose name says
335
+ columns and whose body is a `DataGrid` of ROWS — see
336
+ [templates.md §"`Board` vs `tpl_task_board`"](./templates.md).
337
+
256
338
  ### Numbers & charts
257
339
 
258
340
  `KPIStrip` (the dashboard stat band), `SummaryLine` (the light inline register/list summary
@@ -1154,7 +1236,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1154
1236
  a list too small for its own row.
1155
1237
  Pass `actions` UNCONDITIONALLY and `disabled` the verb when it has nothing to act on: a slot
1156
1238
  that appears once the value is non-empty resizes the field as the user types.
1157
- - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
1239
+ - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units, `align="right"`
1240
+ to put the RESTING figure on its slot's far edge so a typed money column shares one edge —
1241
+ data_entry.md §Billing),
1158
1242
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
1159
1243
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
1160
1244
  — locale field order, separator advances, Alt+ArrowDown floats the calendar; click still
@@ -1302,6 +1386,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1302
1386
  line box, so the band is the same height either way and a loading record's header does not
1303
1387
  move when the number lands. Still ONE number: a node is a stand-in for it, not a licence to
1304
1388
  put a control in the slot.
1389
+ - **`charge_lines`** — `ChargeLines` + `ChargeLine`: the editable money band — what a record
1390
+ CHARGES, priced line by line, closed by its own total. The write side of `Ledger`'s subject,
1391
+ and not interchangeable with it: a ledger has nothing to type into and this has no notion of
1392
+ adjustments. A charge is ONE line — `quantity`/`unitPrice` derive the amount and the amount is
1393
+ never typed; leave both out and the `amount` itself becomes the editor (`amountActions` puts a
1394
+ list price one tap away, `unitPriceActions` a rate). Verbs sit BESIDE the field, never inside
1395
+ it, and the row's own verb (`action`, usually removal) sits in a slot held open on every row —
1396
+ both so the money column cannot move between rows. `extra` carries a second control belonging
1397
+ to the charge (how it was paid); `warning` names a problem on the line that has it; `locked`
1398
+ turns the editors into values for a settled band and draws no verb of any kind; `empty` speaks for a record nobody has priced
1399
+ yet. `formatMoney` is the BAND's, so two lines cannot disagree about a currency, and the `total`
1400
+ you pass must be the sum of the lines on screen. Below its fork width the row takes two lines
1401
+ with the arithmetic right-aligned to the band — see § "Money you are PRICING" for the full
1402
+ rules. Worked example: [`tpl_money`](../examples/tpl_money.tsx).
1305
1403
  - **`ledger`** — `Ledger` + `LedgerGroup` + `LedgerBasis` + `LedgerRow` + `LedgerTotal` — the
1306
1404
  record-level money list: every figure on ONE right-aligned tabular column, `peek` turns a row
1307
1405
  into a pressable door floating its particulars in an anchored popover (put links INSIDE the
@@ -1514,6 +1612,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1514
1612
  - **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
1515
1613
  [Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
1516
1614
  a11y via `SortHeaderLabels`.
1615
+ - **`board`** — `Board` + `BoardColumn` + `BoardCard` + `BoardMove` + `BOARD_COLUMN_WIDTH`:
1616
+ the column board — piles you MOVE records between (see
1617
+ [Records in PILES](#records-in-piles-you-move-them-between--the-column-board)). The board
1618
+ owns the horizontal scroll; a column takes a `heading` node, a `columnKey` and a `count`
1619
+ prop; a card takes `moves` + `onMove`, which drive the keyboard menu AND the pointer drop.
1620
+ Strings resolve through the `board` locale slice (`open` / `move` / `moveTo` / `empty`),
1621
+ overridable per card with `openLabel` / `moveLabel` and per column with `emptyMessage`.
1517
1622
  - **`pagination`** — `Pagination` (+ `PaginationLabels`): the register's pager — a range and two
1518
1623
  arrows, no page index. ONE per register, ABOVE the rows on the `SummaryLine` row; its range is
1519
1624
  the register's count (pair with `Table` `counted`), and `onPageChange` owes a scroll reset the
@@ -1857,9 +1962,15 @@ component rather than showing it at zero.
1857
1962
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
1858
1963
  `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
1859
1964
  RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column — and row
1860
- labels carry default ink, because a row's label is its identity, not chrome. `MATRIX_COL_GAP` is
1965
+ labels carry default ink, because a row's label is its identity, not chrome. **The totals column
1966
+ takes one flex share like a data column**, because a total is the longest figure in its row: on a
1967
+ fixed width it is the FIRST thing to clip, and a clamped figure is whole in the DOM, so nothing
1968
+ but a rendered screen sees it. `MATRIX_COL_GAP` is
1861
1969
  exported for a caller budgeting
1862
- its own column widths.
1970
+ its own column widths. **A money matrix states its unit once** — in the card's
1971
+ `CardHeaderMeta`, cells bare (`formatCompactNumber`, not `formatMoney`), per
1972
+ [composition.md](./composition.md) §"A column states its unit ONCE": one currency glyph per cell
1973
+ is the same word fifty times and it is what pushes the totals column past its share.
1863
1974
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
1864
1975
  aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
1865
1976
  KPI can be driven off the same numbers the grid shows.
@@ -2070,6 +2181,10 @@ component rather than showing it at zero.
2070
2181
  NODE** as well as a string, because a second line can itself be carrying a change (a file
2071
2182
  being RECLASSIFIED is the same bytes filed differently — a `DiffValue` on the property); a
2072
2183
  node renders outside the muted single-line `Text`, which would clip it and fight its colour.
2184
+ It also takes the **field annotations** (`description` / `warning` / `error`) — an expected
2185
+ document is a value the reader supplies, so a row whose document FAILS says so on the row, in
2186
+ the same block `DetailRow` renders; without it a fault could only be demoted into a callout
2187
+ about the whole section, which says something different.
2073
2188
  Both `sm` variants share ONE beat (48px min + 6px vertical padding).
2074
2189
  - **`file_rows`** — `FileRows`: batteries-included file list — row press → built-in gallery
2075
2190
  + a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
@@ -8,6 +8,24 @@ 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
+ **Arrived here because a screen LOOKS wrong?** The visual law sits in the last fifth of this file,
12
+ behind the structural law — so the sections that answer that complaint are listed here rather than
13
+ found by reading forward:
14
+
15
+ | The report | The section |
16
+ |---|---|
17
+ | "it looks like a spreadsheet" | [The register's own craft](#the-registers-own-craft--what-it-looks-like-a-spreadsheet-actually-is) |
18
+ | "it looks plain / flat / unfinished" | [Identity marks](#identity-marks--a-mark-that-is-the-same-on-every-row-carries-nothing) — a register whose rows open with text starts flat, and the symptom is reported as anything but the cause |
19
+ | "it's bland" after every defect is fixed | [Character comes from the DATA](#character-comes-from-the-data--the-answer-when-a-screen-reads-bland) |
20
+ | colour, accent, type, density | [Color discipline](#color-discipline--solid--tint--ramp--one-accent) · [Where the accent goes](#where-the-accent-goes--where-you-are-and-nothing-else) · [Typography](#typography) · [Touch & whitespace](#touch--whitespace) |
21
+
22
+ Those are the reports these sections were written to answer, in their own words — not the whole
23
+ visual law, which is the rest of the file.
24
+
25
+ **Reading them is not the same as checking them.** [reviewing.md](./reviewing.md) is how you find
26
+ out whether a screen you built actually holds these — render, look, measure — and it belongs
27
+ before a deploy rather than after a complaint.
28
+
11
29
  ## The form comes before the treatment
12
30
 
13
31
  **If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
@@ -841,6 +859,14 @@ surfaces consult `pressSelectedText()` instead, so a release that ENDED a select
841
859
  open the record and throw that selection away. Only a real, non-empty selection suppresses: a
842
860
  swallowed ordinary click would leave the row impossible to open.
843
861
 
862
+ **Every gesture that ends on a row owes the same guard.** Selection is one source; a DRAG that
863
+ ends inside the row it started in is another, and the next one will not be on this list. A row
864
+ carrying both a door and a gesture must suppress the press for that gesture's release — and the
865
+ release is what to watch, not the movement: a long drag ends far from where it began and never
866
+ reaches the door, so the case that reproduces the bug is the SHORT one. Suppress on the specific
867
+ gesture having just finished, never by disabling the press while a gesture is possible, which
868
+ takes the door away from every reader who was only ever going to click.
869
+
844
870
  Selection covers every value at once, so reach for **`CopyButton`** only where one value is copied
845
871
  often enough to earn a control (a phone number a rep dials, a reference quoted into a message) —
846
872
  and put it ON that value inside its cell, never in the row's trailing column, which has nothing to
@@ -300,6 +300,13 @@ The box is drawn at the budget and the value simply exceeds it: no ellipsis, no
300
300
  scrollbar, nothing to scroll. So the test is not "is this field long" but **"who decides the
301
301
  length"** — the field, or whoever is typing.
302
302
 
303
+ **One field kind answers that question the same way every time: the NOTE.** A remark, a comment,
304
+ a "ghi chú", an internal note — whatever a surface calls the box a person types free prose into,
305
+ its length is the author's by definition, so it takes `autoGrow` without the test being re-run.
306
+ It is named here because the abstract test kept being answered wrong for it: a note reads as
307
+ short (most are one line), so it gets the default budget, and the one entry that actually
308
+ mattered — the sentence explaining the exception — is the one silently cut.
309
+
303
310
  **And it gets worse as the surface narrows**: a budget is a count of LINES and the wrap point
304
311
  moves, so the narrower column wraps the same value into more hidden lines. A fixed reserve
305
312
  verified on a wide screen is not verified.
@@ -341,6 +348,17 @@ on top would make inline fields the one control in the kit with a second hover l
341
348
  reader crossing a record surface would meet both. Open adds the 2px ring. A `disabled` editor rests FLAT and borderless
342
349
  automatically — the surface is the editability promise, and an inert field must not make it.
343
350
 
351
+ **The surface is not the only promise, and dropping it alone makes things worse.** A select's
352
+ chevron and a date field's calendar glyph each say "something opens here"; once the frame and the
353
+ hover are gone, that glyph is the ONLY mark left calling the row a control, so a value you cannot
354
+ change reads as an editable one that ignores the click. A disabled field therefore renders no
355
+ trailing affordance either — what is left is plain text, which is what a read-only value is.
356
+ The same applies to the PLACEHOLDER: "Choose a…" on an inert field invites an act that cannot
357
+ happen, so a disabled empty field says what is true ("None recorded") instead of what to do.
358
+ Check it by disabling one field in a stack and looking down the column — every field that cannot
359
+ be edited should be indistinguishable from a static value, and one that still carries a glyph or
360
+ an inviting placeholder is the odd one out.
361
+
344
362
  **`variant` decides how much of that frame shows AT REST — an axis of WEIGHT, not of use.**
345
363
  `"framed"` (default) keeps the surface visible: required wherever editable and static values MIX,
346
364
  because the frame is the only thing saying which values you can change. `"bare"` shows nothing at
@@ -423,6 +441,26 @@ surface look cluttered — not the number of rows.
423
441
  | More options, or long labels, or the value is set once and read after | **`InlineSelect`** | scales to any count; costs two presses (open, pick) and hides the alternatives until you do — put each option's gloss in `renderOptionContent`, where it is needed while CHOOSING rather than on every later read |
424
442
  | The choice IS the task — a wizard step, a short focused form | **`RadioPicker`** | full-width choices with their descriptions permanently visible. In a grid of many fields this is ~4 rows of height for one value; never use it as a row in a record's field table |
425
443
 
444
+ **A chip may carry an ICON, and it earns one only when the set differs in KIND.** Payment
445
+ methods, channels, document types — a glyph plus `iconColor` separates *the money is here* from
446
+ *it is not* before the labels are read. On a set that differs in DEGREE (Low / Medium / High) the
447
+ same glyphs are decoration every chip pays width for. Never icon-only: the label stays, because a
448
+ chip that is a bare glyph has a value nobody can read aloud.
449
+
450
+ **Appending a line to a list is create-then-refine too, even when the new line must pick its
451
+ KIND first.** A dialog that exists only to choose one of N and press Save is three presses for one
452
+ act, and it leaves the reader looking at a form instead of the list they were reading. Put the
453
+ choice ON the add control — a `Popover` of the kinds not already present — and let the chosen line
454
+ land in the list with its defaults, refined in place like any other row.
455
+
456
+ Where that control sits follows the list's own shape: **at the END of the rows when the list is
457
+ read in order** (a `DetailTable` of lines, where the next line is what comes after the last one and
458
+ the section heading carries the section's identity, not a per-list verb), and **on the section
459
+ heading when the list is a `Table`** (which already owns a header row and a toolbar for acts that
460
+ are about the whole set). One primary either way — if the section already has a committing act,
461
+ the add is secondary, because appending a rare line must not outrank the act the section exists
462
+ for.
463
+
426
464
  **A choice that GATES the form is a fourth answer, and the answer is usually "don't ask".** The
427
465
  table above is for a choice that IS a value. A choice about how to proceed — which sign-in method,
428
466
  which import format, which template — is not a field at all, and putting it on its own screen in
@@ -730,8 +768,24 @@ line saying what's needed; the payment-method picker turns required the instant
730
768
  amount. Issuing a real e-invoice is irreversible → confirm in a `Dialog` (stage gate). A
731
769
  grand-total **receipt** validates first — surface the EXACT missing methods (`Alert.alert` listing
732
770
  each) rather than a vague "incomplete." A refundable **deposit** is its own card and its own
733
- receipt — never folded into the total due. Composition over `Card` + `NumberInput` + `Picker` +
734
- `Badge`; no new primitive. Worked example: `tpl_record`'s Billing section.
771
+ receipt — never folded into the total due. Where the band's lines are PRICED a quantity against
772
+ a rate that band is `ChargeLines` + `ChargeLine` (`@lotics/ui/charge_lines`), which owns the
773
+ one-line charge, the two money columns and the closing total; hand-rolling it is what produced the
774
+ defects listed in [catalog.md §"Money you are PRICING"](./catalog.md). Worked examples:
775
+ `tpl_money`, and `tpl_record`'s Billing section.
776
+
777
+ **An EDITABLE amount still owes the reader a money column.** An `Inline*` editor fills the value
778
+ slot and left-aligns its text, while the figures derived from it — a line's extended amount, the
779
+ total — right-align like money always does. Left alone that is three ink positions for three
780
+ numbers the reader is meant to add up, and no width fixes it because the two are aligned on
781
+ opposite edges. Give the editor a FIXED width and `align="right"` — the `Inline*` prop that moves
782
+ the RESTING value onto its slot's far edge, which is the half a width alone cannot buy: the
783
+ amounts you type form one column, the amounts the system computes form the next, and both close on
784
+ the same edge as the total beneath them. Measure it by collecting the right edge of every money
785
+ string on the surface — a correct one shows two values, not one per row. A one-tap verb beside the
786
+ figure (a standard rate, a list price) is a THIRD edge and not a defect; what it must never do is
787
+ sit INSIDE the editor in a money column, where it eats the field's width and slides the figure off
788
+ the column its neighbours are on. In a form row whose value column grows, on the field is fine.
735
789
 
736
790
  ## Fee summary — `Ledger`
737
791
 
package/docs/reviewing.md CHANGED
@@ -225,6 +225,17 @@ spacing distinguishes them.
225
225
  fixed neighbouring shade under the pointer. Watch for an opaque overlay hiding a themed ground.
226
226
  - **A prop's EFFECT must match its claim** — render each value and diff what actually moved
227
227
  against what the prop says it changes. The fix is in the component, never the caller.
228
+ - **A prop with NO effect is invisible to that diff**, because every value renders the same
229
+ screen. It typechecks, it lints, the caller reads correctly, the docs describe it — and it does
230
+ nothing, sometimes for releases. Read it off the SOURCE instead of the screen: a name that
231
+ appears in the props interface and in the destructure and **nowhere else in the render** is
232
+ dead. Wire it, then cover it with a test that goes red when the render line is deleted — a
233
+ component test asserting the passed node reaches the output, since nothing about the type system
234
+ will ever notice. **Three shapes look dead and are not**, so check them before reporting: a rest
235
+ spread (`const { a, ...inline } = props`) forwards a prop without ever naming it; a renamed
236
+ destructure (`slices: chartData`) uses it under the new name; and a platform fallback file
237
+ deliberately ignores a prop its `.web` twin honours. Sweep a component's WHOLE prop list at
238
+ once — a verb prop that shipped dead usually has a twin on the sibling field, dead the same way.
228
239
  - **Measure a component against ITSELF under each optional prop** that should not change size — a
229
240
  file row measured 37px static and 49px with an `onPress`, because only the pressable variant
230
241
  carried the padding its wash needed. Same for a slot that may be empty.
@@ -240,6 +251,29 @@ spacing distinguishes them.
240
251
  become double-corrections the moment the row is right. Two exceptions are legitimate: a
241
252
  self-contained overlay has nothing to align with, and an indent something VISIBLE occupies (a
242
253
  checkbox, an ordinal, a mark) is explained.
254
+ - **A number you can EDIT and a number you can only READ drift apart on a money column.** An
255
+ inline editor fills its slot and left-aligns; the figures derived from it right-align. Collect
256
+ the right edge of every money string on the surface — a sound one lands on TWO values (the typed
257
+ column, the computed one), a broken one lands on as many values as there are rows, and the
258
+ reader can no longer add the column up by eye. The band's TOTAL is part of the computed column
259
+ and must be counted; a one-tap verb beside a field (a standard rate) is a third edge and is not a
260
+ defect. **Re-run the probe at a width where the row FORKS to two lines** — a forked row is laid
261
+ out by different rules and can land its amounts on an edge the total is not on, which is the one
262
+ failure the wide measurement cannot see. Rule: data_entry.md §Billing.
263
+ - **A derived result must END the expression it derives from.** Where a row spells out a
264
+ calculation — a quantity, an operator, a rate — collect the right edge of the RESULT and of the
265
+ last operand. Equal is sound; anything else makes the eye read the expression left-to-right and
266
+ then jump backwards to find its answer, and putting the result in a label/description slot is
267
+ the worst version (it lands at the START of the line). The near-miss has one cause worth naming:
268
+ the result was positioned by a HAND-ADDED width, and one term of the sum — usually the operator
269
+ glyph, whose width the font decides — was guessed. Give that glyph an explicit width and build
270
+ both offsets from the same named constants, or the two edges agree only at the font size you
271
+ happened to screenshot.
272
+ - **A DISABLED control that still wears its affordance.** Query every field for
273
+ `aria-disabled="true"`, then ask what separates it from an editable neighbour. Frame gone but
274
+ chevron/calendar glyph still drawn, or an inviting placeholder ("Choose a…") on an inert field,
275
+ is a value that reads as editable and ignores the click — the complaint arrives as "I can't edit
276
+ this and I don't know why". Rule: data_entry.md §The editability affordance.
243
277
  - **A CENTRED child hides its own drift.** Centring puts a child's top at `(container − child)/2`,
244
278
  so a child whose size varies with DATA moves its own first line between instances while the
245
279
  container measures identical every time. It takes a container with SLACK to bite. Wherever a
package/docs/templates.md CHANGED
@@ -40,10 +40,11 @@ composition grammar](./composition.md); the package index is [../AGENTS.md](../A
40
40
  | A guided sequence of physical tasks (scan, confirm, next) | `tpl_pick` |
41
41
  | Splitting one source amount across many targets | `tpl_allocate` |
42
42
  | A record's create/edit surface — also the settings shape | `tpl_record` |
43
- | A surface whose SUBJECT is tasks (the canonical task-list grammar) | `tpl_task_board` |
43
+ | A surface whose SUBJECT is tasks, managed in a grid of live cells | `tpl_task_board` |
44
+ | Advancing records between STAGES | `tpl_board` |
45
+ | Records as CARDS in columns, moved between them — the column board | `Board` (`@lotics/ui/board`) |
44
46
  | One record HANDED between desks — stages that each own their controls | `tpl_record` (its Progress section) |
45
- | Financial statements | `tpl_statements` |
46
- | A scoped lookup report with export | `tpl_report` |
47
+ | ONE record's money — what it charges, costs, bills and collects | `tpl_money` |
47
48
  | A week calendar + agenda | `tpl_calendar` |
48
49
  | An attendance desk | `tpl_attendance` |
49
50
  | Shift signup + staffing | `tpl_shifts` |
@@ -387,6 +388,13 @@ The corollaries, each of which a register is routinely missing:
387
388
 
388
389
  - **One toolbar row** — search + a status `Select` + facet `FilterChip`s LEFT, the New CTA
389
390
  RIGHT; then a light `SummaryLine` of the filtered view BELOW it, above the rows.
391
+ **Search is ALWAYS the leftmost control**, and the order after it is stated rather than left to
392
+ the author: search, then the scope the rest of the row filters WITHIN (a site, an account, a
393
+ period), then the facets. Search is the one control every register carries and the one a reader
394
+ reaches for without looking, so it holds the same spot on every screen in the product; a scope
395
+ `Select` placed before it moves the anchor per screen and costs the reader a scan each time.
396
+ This is the register's counterpart to the add-placement law — the position is the affordance,
397
+ and an author choosing it per screen is the defect.
390
398
  - **A sortable `Table`** — rows separated by their own height and the hover wash, with the one
391
399
  hairline under the column band. It sets no row height: the register's rhythm is the kit's. →
392
400
  [composition.md](./composition.md) §"The register's rhythm". The record's SUBJECT is the first
@@ -892,7 +900,32 @@ a REGISTER (`tpl_item_list`) with a derived stage column — the same ladder mod
892
900
  so a row's badge and its filter bucket can never disagree. Rows that are structurally identical
893
901
  and tickable in any order are a `tpl_task_board`, not a pipeline.
894
902
 
895
- ### `tpl_task_board` — the columns shape
903
+ ### `tpl_board` — the column board
904
+
905
+ Columns are the STAGES of a pipeline, cards are records, and the only act is sending a card
906
+ onward. Reach for it when the question is *"what is stuck, and what moves next?"* — the pile sizes
907
+ are themselves the report, which is the thing a register at equal weight cannot say.
908
+
909
+ - **The move is KEYBOARD-first.** Each card carries a control whose items name their destinations;
910
+ pointer drag is layered on that same declared list, so the two paths cannot disagree about what
911
+ is legal. An act reachable only by drag is unreachable for a keyboard user and untestable by a
912
+ driver.
913
+ - **A column states its size as a PROP**, never formatted into its heading.
914
+ - **The BOARD owns the horizontal scroll.** The page never does.
915
+ - **A card is a DOOR** into the same record surface a register opens — the board is another way in,
916
+ never a second detail view.
917
+
918
+ **Not [`tpl_task_board`](../examples/tpl_task_board.tsx)**, which is a `DataGrid` of ROWS with an
919
+ inline editor in every cell: you ADVANCE work here and COMPARE-and-correct it there. The name is
920
+ the trap — read the source.
921
+
922
+ ### `tpl_task_board` — the inline-managed task grid
923
+
924
+ **It is not a column board, and the name is the trap.** This template is a `DataGrid`:
925
+ collapsible BANDS OF ROWS with inline-editable cells. Nothing is arranged in columns of cards
926
+ and nothing is dragged between them. A view built on it and labelled *Kanban* is mislabelled —
927
+ for the column shape reach for **`Board`** (`@lotics/ui/board`), and read
928
+ §"`Board` vs `tpl_task_board`" below before choosing.
896
929
 
897
930
  The manager's board: a search, group-by, filter toolbar over a grouped, sortable grid of
898
931
  LIVE editable cells — assignee/due/status/tags ALL via `Inline*` editors in **`variant="bare"`**
@@ -923,31 +956,59 @@ only real dimensions, ✕ clears to ungrouped (no "Nothing" option).
923
956
  manage in view (group/sort/filter/edit-in-place; it renders ALL rows). For thousands+ you
924
957
  BROWSE — that's `tpl_item_list`'s paginated register, not this.
925
958
 
926
- ## Finance
927
-
928
- ### `tpl_statements` the statement grammar
929
-
930
- The income statement, balance sheet, and cash flow statement in ONE statement grammar:
931
- right-aligned column captions over fixed money columns, items indented under group headers, a
932
- hairline rule above every subtotal, the grand total DOUBLE-RULED (the accounting convention),
933
- negatives in accounting parentheses, per-cell currency-free (the meta line names the currency
934
- once), no bars, no charts the numbers are the interface. Composed from `Text` + `Divider`
935
- alone: it teaches a grammar, not a component. The three statements TIE net income flows
936
- into retained earnings, closing cash IS the balance sheet's cash, the loan repayment moves
937
- the debt line. Keep your books reconciled when adapting the data, or the screen teaches the
938
- wrong thing.
939
-
940
- ### `tpl_report` the scope-first lookup report
941
-
942
- Answer a question by SCOPING a dataset, not by browsing it. The scope bar leads: a header
943
- period (`DateRangeFilterField`) + ONE search key chosen from several mutually-exclusive
944
- dimensions (`SegmentedControl` swaps the matching picker; switching the key resets the
945
- value). That scope drives everything below — KPI totals → a row of pressable `Breakdown`
946
- facets (the long-tail one folds behind `maxRows`) → the paginated register of matching lines
947
- → an Export button. Leave the search empty and the period alone gives the whole-period
948
- report. The fee / transaction / usage lookup desk.
949
-
950
- ## Scheduling
959
+ ### `Board` vs `tpl_task_board` — the same data, two different jobs
960
+
961
+ Both divide one field's values into piles. They are not variants of each other, and the
962
+ discriminator is what the reader came to DO.
963
+
964
+ - **`Board`** (`@lotics/ui/board`) when the job is to ADVANCE work the pile sizes are the
965
+ report, and moving a card from one pile to the next is the act the screen exists for. Columns
966
+ are the field's values, cards are records, and each card carries a `moves` list naming its
967
+ destinations. That list is the keyboard path (a menu) AND the pointer path (a drag drops onto
968
+ the column under the pointer and is refused unless the destination is in the same list), so the
969
+ two can never offer different answers. Compare across cards is what it is BAD at: only what
970
+ fits on a card is visible, and nothing lines up.
971
+ - **`tpl_task_board`** when the job is to COMPARE and EDIT — assignee against due date against
972
+ priority, twenty rows at a time, each cell an inline editor. Bands, not columns; the field is
973
+ changed in its own cell rather than by moving the row.
974
+
975
+ Grouped rows answer "what is in each pile" and stop there. **A view labelled Kanban over bands
976
+ of rows is the wrong FORM, not a smaller version of the right one** — the move is the whole
977
+ shape, and a grid hands it back as a cell edit two screens from the pile it belongs to.
978
+
979
+ ## Money
980
+
981
+ ### `tpl_money` THE money surface for one record
982
+
983
+ Everything one record's money does, on one page, in the four shapes it takes — and the split that
984
+ governs it: **`ChargeLines` is what you are PRICING and `Ledger` is what you are READING**, a band
985
+ you type into versus a statement you check.
986
+
987
+ - **Fees** — the DETAILED view, both directions (charge / cost), each fee carrying its party, due
988
+ date, supplier original and paid state. A register whose every row EXPANDS in place to its full
989
+ nine-field detail, one at a time: a child collection inside a record never docks a drawer,
990
+ because the page already IS the context the drawer would recreate. The section's Add rides the
991
+ heading row, where it does not move with the row count.
992
+ - **Billing** — the invoice DOCUMENTS. Each invoice costs ONE line of chrome (its name, its
993
+ total, its reference once issued — the reference in a HELD-OPEN slot, or it drags the totals off
994
+ the column the statement below closes) over its own charge rows, with the issue act at the end
995
+ of what it commits. A problem rides the CHARGE ROW that has it, never a callout counting
996
+ offenders. The section closes on a three-sided `Ledger` — billed, less credited, less received —
997
+ and the refundable deposit, which is deliberately outside the total to collect.
998
+ - **The charge bands** — `ChargeLines` in every shape: priced lines (`quantity × unitPrice`, the
999
+ amount never typed), flat amounts (the amount IS the editor, with the payment method riding the
1000
+ charge's own line via `extra`), mixed kinds landing on one column, `locked` once collected, the
1001
+ empty band, and the narrow fork — the SAME band rendered in a 360 box, because the fork is
1002
+ measured on the band's container and not on the window.
1003
+
1004
+ **The law the page is checked against:** every band lands on TWO money edges — the typed column
1005
+ and the amount column, with the band's total in the amount column. A one-tap verb (a standard
1006
+ rate) is not a column figure and may sit elsewhere. Verify by measuring: collect the right edge of
1007
+ every money string per band at a wide and a narrow width (→
1008
+ [reviewing.md](./reviewing.md), and [data_entry.md §Billing](./data_entry.md)).
1009
+
1010
+ Its Fees and Billing sections are the same two sections `tpl_record` carries — read them there
1011
+ when you want them in the context of a whole record surface, and here when money is the subject.
951
1012
 
952
1013
  ### `tpl_calendar` — the week desk
953
1014