@lotics/ui 43.4.0 → 43.6.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 (68) hide show
  1. package/AGENTS.md +3 -2
  2. package/MIGRATION.md +93 -0
  3. package/docs/catalog.md +128 -26
  4. package/docs/composition.md +395 -9
  5. package/docs/data_entry.md +25 -0
  6. package/docs/reviewing.md +477 -0
  7. package/docs/templates.md +9 -6
  8. package/examples/tpl_attendance.tsx +0 -1
  9. package/examples/tpl_item_list.tsx +109 -54
  10. package/examples/tpl_record.tsx +89 -4
  11. package/package.json +7 -3
  12. package/src/avatar.tsx +29 -29
  13. package/src/avatar.web.tsx +32 -31
  14. package/src/avatar_props.ts +66 -0
  15. package/src/avatar_tone.ts +79 -0
  16. package/src/button.tsx +36 -4
  17. package/src/checkbox.tsx +4 -1
  18. package/src/choice_list.tsx +5 -3
  19. package/src/color_tokens.ts +15 -14
  20. package/src/composer.tsx +3 -3
  21. package/src/control_surface.ts +37 -9
  22. package/src/copy_button.tsx +8 -1
  23. package/src/counter.tsx +1 -1
  24. package/src/data_grid.tsx +6 -3
  25. package/src/date_calendar.tsx +4 -2
  26. package/src/date_range_filter_field.tsx +5 -1
  27. package/src/date_segments_field.tsx +2 -2
  28. package/src/file_drop_target.web.tsx +2 -2
  29. package/src/file_dropzone.tsx +12 -6
  30. package/src/file_rows.tsx +22 -2
  31. package/src/file_thumbnail.tsx +19 -3
  32. package/src/font_family.ts +1 -1
  33. package/src/font_family.web.ts +1 -1
  34. package/src/funnel.tsx +1 -1
  35. package/src/icon_button.tsx +5 -2
  36. package/src/index.css +22 -16
  37. package/src/inline_edit.tsx +9 -10
  38. package/src/inline_files.tsx +6 -0
  39. package/src/json_panel.tsx +1 -1
  40. package/src/kpi_card.tsx +1 -1
  41. package/src/locale.tsx +1 -1
  42. package/src/markdown.css +5 -1
  43. package/src/metric.tsx +24 -24
  44. package/src/number_input.tsx +2 -2
  45. package/src/option_picker.tsx +57 -0
  46. package/src/picker.tsx +2 -2
  47. package/src/pressable_row.tsx +15 -5
  48. package/src/progress_bar.tsx +1 -1
  49. package/src/radio_picker.tsx +2 -1
  50. package/src/search_input.tsx +12 -11
  51. package/src/sort_header.tsx +7 -3
  52. package/src/stacked_progress_bar.tsx +1 -1
  53. package/src/step_progress.tsx +5 -2
  54. package/src/switch.tsx +11 -6
  55. package/src/table.tsx +158 -16
  56. package/src/table_fit.ts +12 -0
  57. package/src/tabs.tsx +13 -1
  58. package/src/text.css +50 -18
  59. package/src/text.tsx +37 -40
  60. package/src/text_input_field.tsx +2 -2
  61. package/src/text_utils.ts +46 -6
  62. package/src/theme.ts +13 -0
  63. package/src/theme.web.ts +49 -0
  64. package/src/theme_vars.ts +113 -0
  65. package/src/type_ramp.ts +113 -0
  66. package/src/theme.tsx +0 -24
  67. package/src/theme.web.tsx +0 -79
  68. package/src/theme_context.ts +0 -107
@@ -33,6 +33,26 @@ them produces off-system UI.
33
33
  | Register / work list / calendar | 1040 |
34
34
  | Wide wallboard / report | 1100 |
35
35
 
36
+ **ONE LEFT EDGE — the whole page, and then the whole APP.** Headings, detail labels, prose, a
37
+ table's first column, a footer's totals, a dialog's title. Then check the screen this one OPENS: a
38
+ list and the record it opens are one reading column seen twice, and both must derive from ONE
39
+ constant rather than happening to match today.
40
+
41
+ The offender is nearly always a row that PADDED for its own hover wash instead of bleeding it
42
+ (`ROW_WASH_BLEED`), which pushes its content in by the bleed while everything above it stays put.
43
+ Fix the row, then DELETE the compensators that were added around it — once the row is right they
44
+ become double-corrections, and they are the reason the edge drifts back the next time someone
45
+ touches it. Two exceptions are legitimate and neither needs a fix: a self-contained overlay (a
46
+ menu, a popover) has nothing to align with, and an indent that something VISIBLE occupies — a
47
+ checkbox, an ordinal, an identity mark — is explained by the thing occupying it.
48
+
49
+ **A row beat belongs to the PAGE, not to the row.** Collect every row primitive's height on one
50
+ screen before calling any of them right — the kit's own defaults do not agree with each other,
51
+ because each was picked for its own surface: `ListItem` is 64, a ruled `Table` row is 52, a
52
+ `Button`-height control is 40. Stack three of those in one column and it reads as three rhythms,
53
+ each defensible alone. The page picks one beat and the primitives take it (`minHeight` on the
54
+ row, `spacing` on the table).
55
+
36
56
  ## The heading ramp — one construct per altitude, no drift
37
57
 
38
58
  **The ramp is FIXED — the markdown ladder, no size knobs.** One outline on every surface; never
@@ -45,9 +65,21 @@ restyle a heading level per-page.
45
65
  | `###` | `lg` semibold | Subsection title — `SubsectionHeadingTitle`, always |
46
66
  | body | `sm` | Everything else |
47
67
 
48
- - **Page band** — one per screen: a `Text size="xxl" weight="semibold"` title (`#`) + one `sm`
49
- muted subtitle that says what the screen decides. Right side: the screen's ONE primary action
50
- and/or a period filter never a summary `Badge` (those belong to the KPI strip).
68
+ - **Page band** — the screen's TYPE ANCHOR, and only secondarily a label. A register's own type
69
+ runs 12–16px, so a page without a band tops out around a **1.33x** range between its largest and
70
+ smallest text, and that reads flat no matter how much colour is on it — scale is the one
71
+ hierarchy device colour cannot substitute for. Restoring a `Text size="xxl" weight="semibold"`
72
+ title takes the same screen to ~2.7x. It also gives the page somewhere to land: without it the
73
+ first thing on the screen is a search input, which is chrome.
74
+ Yes, the title usually repeats the nav. Accept the repetition — a heading that names the screen
75
+ costs one line and buys the whole type range, and the alternative (inventing a title the nav
76
+ does not say) is worse.
77
+ **A second line carries LIVE STATE, never a gloss.** "Track each record's status and print the
78
+ right document for its stage" explains the WIDGET, and a band has no use for it. But a
79
+ register's counts are not that line either: they are the result of the TOOLBAR, so they belong
80
+ under it — see § Summaries.
81
+ Right side: the screen's ONE primary action and/or a period filter — never a
82
+ summary `Badge` (those belong to the KPI strip).
51
83
  - **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
52
84
  `CardHeaderTitle` (`sm` semibold; pass `info` whenever the title alone doesn't define the
53
85
  numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
@@ -222,6 +254,43 @@ by WHAT THE SURFACE IS; never a raw `Pressable`.**
222
254
  | Pills / toggles | `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`, `Tabs`, `Switcher`, `RadioPicker` | a pill SURFACE | washes it (white → zinc-100; pressed zinc-200); selected adds a ring/fill | `chipSurfaceStyle` |
223
255
  | Surfaces | `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Accordion` headers | a row/card SURFACE | washes it — the grey wash IS the affordance | `PressableHighlight` |
224
256
 
257
+ **ONE signal per species, and it has to be strong enough to carry alone.** `HOVER_BORDER` is
258
+ zinc-500, not 400, because at 400 the shift was zinc-200 → zinc-400 on a 1px line — measurably a
259
+ change and visually almost nothing, so the inline editors had grown a background TINT on top to
260
+ compensate. That left two hover languages in one kit, and a reader crossing a record surface met
261
+ both. Strengthen the shared signal rather than letting one control add a second; a control wearing
262
+ two is the tell that the first one is too weak for everybody.
263
+
264
+ **A themeable control derives EVERY state from its token — rest, hover, press, disabled.** The
265
+ failure is a control that resolves `colors.primary` correctly at rest and then paints a fixed
266
+ neighbouring zinc the moment it is touched, so a branded button turns grey under the pointer. Watch
267
+ for an OPAQUE overlay especially: a `backgroundImage` gradient drawn over a themed
268
+ `backgroundColor` hides it entirely while every token in the source reads correct. On web a token
269
+ is a `var()`, so "the same colour, lighter" cannot be computed at the call site — use a translucent
270
+ white/black overlay, which lifts any hue and cannot go stale against a palette it does not know.
271
+
272
+ **A control changes PAINT across its states and never GEOMETRY** — and every property it paints
273
+ has exactly ONE owner. Text that shifts on focus, a border that thickens, a ring drawn twice, a
274
+ tint applied on one path only: each is two pieces of code owning one property and happening to
275
+ agree at rest. It survives review because a resting screen is what gets looked at, and the second
276
+ owner only speaks when the control is touched. The fix is to give the property one owner — a
277
+ shared surface helper, a single frame kept across the view→edit swap — never to nudge the second
278
+ owner into agreeing with the first, which leaves both in place for the next person to re-break.
279
+
280
+ **A control riding a hoverable surface must be DARKER than that surface** — and you have to hover
281
+ BOTH to see whether it is, because reaching the control means crossing its host. Equal washes
282
+ vanish at exactly the moment they are pointed at; a control LIGHTER than its host reads as a hole
283
+ punched in the row rather than as a target sitting on it. Neither failure is visible at rest, and
284
+ neither is visible hovering the control alone, which is why a control that measures perfectly can
285
+ still disappear in use. Take `ROW_CONTROL_HOVER` / `ROW_CONTROL_PRESS` (`control_surface`), which are a step past the
286
+ row's own wash for exactly this reason; a hand-picked grey chosen against the resting row is the
287
+ finding however plausible the value looks.
288
+
289
+ **A SELECTED control needs a GROUND, not a heavier outline.** Among white pills a 1px border change
290
+ is noise — it is the same signal hover already spends, one step stronger, so selection and hover
291
+ compete instead of reading as different things. Selection changes what the box IS (a fill, a wash);
292
+ hover changes how intensely it says it.
293
+
225
294
  For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
226
295
  open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. The bug this
227
296
  grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
@@ -264,8 +333,17 @@ objects — hairlines between them blur into one run-on list.
264
333
  | Altitude | Component | Where | Shape |
265
334
  |---|---|---|---|
266
335
  | Dashboard | `<KPIStrip items={[{label, value, trend?, caption?, tone?, compact?, info?}…]}>` | TOP of a dashboard | a boxed stat band — a `Card` of `KPICard` columns |
267
- | Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?, color?}…]}>` | BELOW the toolbar | a light inline line — a small `Metric` + muted label per item |
268
- | Register / list, with a SHAPE | `<Summary buckets={…}><Summary.Header>…</Summary.Header><Summary.Distribution/><Summary.Facts/></Summary>` | BELOW the toolbar | headline figure + distribution bar + the buckets named, all derived from one bucket list |
336
+ | Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?, color?}…]}>` | BELOW the toolbar, above the rows | a light inline line — a small `Metric` + muted label per item |
337
+ | Register / list, with a SHAPE | `<Summary buckets={…}><Summary.Header>…</Summary.Header><Summary.Distribution/><Summary.Facts/></Summary>` | BELOW the toolbar, above the rows | headline figure + distribution bar + the buckets named, all derived from one bucket list |
338
+
339
+ **A summary goes BELOW the controls that produce it.** Its figures are the result of the toolbar —
340
+ they move every time the reader touches a filter — so the layout has to state that order: set the
341
+ query, then read what it returned. Above the toolbar the screen answers before it has been asked,
342
+ and the numbers change behind the reader's eye while they are still choosing.
343
+
344
+ It is not the page band's subtitle either, for the same reason. A subtitle describes the SCREEN,
345
+ which does not change; these describe the QUERY, which the toolbar just rewrote. The band carries
346
+ the title, and the summary sits under the controls, immediately above the rows it counts.
269
347
 
270
348
  `SummaryLine` is `Summary`'s `Facts` row on its own — reach for the compound when the set has a
271
349
  distribution worth drawing, and for the bare line when it is only a few aggregates. A
@@ -445,8 +523,9 @@ Two corollaries, both counter-intuitive enough to state:
445
523
  - **Shrinking a NEIGHBOURING cell does not fix it.** The spread is a function of the varying
446
524
  cell's own range, not of the row's height: a 20/42 cell spreads 11px in a 52px row and 11px in
447
525
  a 68px one. Only bounding the cell that varies removes it.
448
- - **The fix is never a cross-axis knob.** `Table` has no `align` (see MIGRATION 32.0.0): topping
449
- the cells leaves the chrome centred, so the row reads as scatter instead.
526
+ - **The fix is never a cross-axis knob.** A column's `align` is horizontal (`"left"`/`"right"`,
527
+ for a money column); there is deliberately no VERTICAL one, because topping the cells leaves
528
+ the chrome centred and the row reads as scatter instead.
450
529
 
451
530
  Bound a cell by giving every row the same content, not by hoping the data is uniform. A line that
452
531
  appears only when its field is set is the defect; RESERVING the line is the fix — **but only when
@@ -987,6 +1066,264 @@ a pointer. `PressableHighlight` and `FocusRingPressable` set the arrow for every
987
1066
  on them (`CURSOR_DEFAULT`); a component on the bare `Pressable` sets it itself, and the few
988
1067
  that act set `CURSOR_ACTION` back over the top.
989
1068
 
1069
+ ## The register's rhythm — no rule between rows, one under the band
1070
+
1071
+ A register's rows are separated by their own height and a small gap. The only mark a row makes is
1072
+ the rounded wash it paints when you point at it. There is no hairline between rows and no way to
1073
+ ask for one.
1074
+
1075
+ | | |
1076
+ |---|---|
1077
+ | Between rows | nothing — 4px and their own height |
1078
+ | Under the column band | a hairline (`colors.border`) |
1079
+ | Row height | 72, overridable per row with `TableRow`'s `minHeight` |
1080
+
1081
+ **WHAT a line separates is the whole test — not how big the break feels.** A rule between two rows
1082
+ divides LIKE FROM LIKE, and the space between them already says that. The band's rule divides
1083
+ CHROME from CONTENT — a row of labels from the records they name — and no amount of whitespace
1084
+ states that, because whitespace is what already separates the rows from each other. Removing it was
1085
+ tried on a real register and the column band floated: with nothing beneath them the labels read as
1086
+ a first row rather than as the header of the rest.
1087
+
1088
+ Drawing both makes them compete, and a grid of equal lines is most of what "it looks like a
1089
+ spreadsheet" means.
1090
+
1091
+ **The hover wash does the work the rule did**, and it already has the shape: `PressableRow` bleeds
1092
+ its ground past the content and rounds it, so a register reads as a stack of cards with no card
1093
+ component involved. That is why dropping the rules does not make rows ambiguous — the separator
1094
+ moved from a permanent line to a pointer-following one. The row has to be tall enough for that wash
1095
+ to read as a surface rather than a stripe, which is what the 72 buys.
1096
+
1097
+ **Density is a number, never a mode.** A surface that genuinely needs more rows on screen sets
1098
+ `minHeight` on the row. What no caller can ask for is the rules back — and that is deliberate,
1099
+ because a table of VALUES rather than of objects is a different component: `Matrix` for a numeric
1100
+ grid (a statement, a pivot, a rollup), `DataGrid` for a compact one. The distinction the two
1101
+ rhythms used to express already lived at the component boundary; offering it again as a prop was a
1102
+ third way to say the same thing, and the way that let a register contradict this rule.
1103
+
1104
+ ## A register that TRIAGES — `TableGroup`
1105
+
1106
+ A flat register sorted by a column hands the reader every row at equal weight and leaves the
1107
+ deciding to them: "which of these needs me today" is answered by reading all of them. `TableGroup`
1108
+ moves that decision into the screen — a titled band of rows with a count.
1109
+
1110
+ **Group by what implies a different ACTION, never by a category the reader can already see.**
1111
+ Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
1112
+ the grouping tells the reader something no column does. Grouping by a value that is already a
1113
+ column is a sort wearing chrome: it spends a band per value to repeat the cell beside it.
1114
+
1115
+ - **Order the bands by how much they need the reader NOW**, not alphabetically and not by size. A
1116
+ register is read top-down, so the first band is the answer to "what do I do today".
1117
+ A stated next step outranks silence — it is a commitment already made, and it names the work.
1118
+ - **An empty band renders nothing**, never an empty heading: a title over no rows states a pile
1119
+ that is not there.
1120
+ - **At most one band carries a dot.** A valence mark on every band is decoration; on the one band
1121
+ whose urgency comes from a CLOCK rather than from something someone wrote down, it is a signal.
1122
+ - **Air separates bands, a hairline separates rows.** The band boundary is the largest break
1123
+ inside the register and the row rules are the smallest — drawing both as lines makes them
1124
+ compete and the grouping stops reading. No rule under a band heading either; that orphans the
1125
+ title from the rows it opens.
1126
+ - **Ordinals restart inside a band** where they are shown: the reader's question there is "which
1127
+ of these", not "which of all".
1128
+ - **Watch the ratio of headings to rows.** Four bands over ten rows is a lot of chrome for a
1129
+ little content, and the shape only pays off as the register grows. A register that will never
1130
+ hold more than a screenful is usually better flat.
1131
+
1132
+ Banding also changes what the COLUMNS have to carry, and the check is worth running afterwards: a
1133
+ column that exists to signal presence ("is there a next step?") is now stated by the band, so it
1134
+ is left carrying only its text — and it will be empty in every band but one. That is not
1135
+ necessarily wrong (which pile against what specifically), but it is a new duplication to look at.
1136
+
1137
+ ## The register's own craft — what "it looks like a spreadsheet" actually is
1138
+
1139
+ Three defects produce that report, none of them about colour, and all three measured on a shipped
1140
+ screen that had already passed a design audit. (The fourth was drawing a line between every row;
1141
+ that one is settled above — a register has one line, and it is the band.)
1142
+
1143
+ **A column header is a LABEL at the body size** — `sm` / `muted` / `medium`, matching the values
1144
+ it heads in size and separating from them by weight and ink. It is not an eyebrow: an eyebrow is
1145
+ `xs`, calibrated to sit quietly beside a value it captions, and a column header names a whole
1146
+ column of them. Dropped to `xs` and lightened past `muted` it stops being legible and starts
1147
+ reading as a caption that lost its value — small type is not the same as quiet type. The active
1148
+ SORTED column steps up in ink, never in weight; semibold belongs to the heading ladder.
1149
+
1150
+ **Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
1151
+ the #1 templated tell; `Table` and `SortHeader` were uppercasing every column label in every app,
1152
+ which is the loudest possible version of the thing the rule exists to stop. Removing the case
1153
+ without replacing the separation is what left the header looking adrift — the ink step above is
1154
+ the replacement, and the two changes only work together.
1155
+
1156
+ **A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
1157
+ `##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
1158
+ at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
1159
+ separate file, a heavier stroke than the number suggests). The subject leads by SCALE instead: one
1160
+ size step above the values beside it. Moving size and weight together is one step too many.
1161
+
1162
+ **A supporting line is ONE RUNG BELOW the line it supports — never a fixed size.** A `md` subject
1163
+ over an `xs` caption is a 16→12 drop, while the same shape in the next column drops 14→12; the
1164
+ wider pair then reads as though its second line shrank rather than stepped. Pick the rung
1165
+ relative to the primary and the pair scales together. The probe: collect every primary/supporting
1166
+ pair on the screen and divide. A spread in the RATIOS is the finding, not a spread in the sizes.
1167
+
1168
+ **And every pair ON ONE ROW sits at the SAME rungs — a register's row subject is `sm`, its
1169
+ supporting line `xs`, and a value in any other column is `sm` too.** "One rung below" is only half
1170
+ a rule: it constrains the STEP and says nothing about where the pair starts, so a subject placed a
1171
+ rung high stays compliant while dragging its whole column above the rest. Measured on a register
1172
+ that read as disproportionate: the identity column ran 16/14 while the column beside it ran 14/12
1173
+ — both correctly "one rung apart", one whole rung out of step with each other, and the first column
1174
+ shouted while everything else receded.
1175
+
1176
+ **The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
1177
+ plus the avatar or tile that answers "which row is this", is enough — and it is what
1178
+ `tpl_item_list` does. Money is the case that most often argues for a size step and should not get
1179
+ one: tabular figures and right alignment already mark it, and a lone larger value on a row reads as
1180
+ a mistake rather than as emphasis. One larger thing among peers is not emphasis; it is the thing
1181
+ that looks broken.
1182
+
1183
+ **A CONTROL riding a text line must not resize that line.** A control is taller than the text it
1184
+ sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
1185
+ the CONTROL, and `alignItems: "center"` then spends the difference above the text. The line's text
1186
+ starts lower, and the pair reads with a wider gap than the identical pair one column over that
1187
+ happens to carry no control. Measured on a register: 5px under the name against 2px under the
1188
+ figure beside it, from one `gap: 2` shared by both.
1189
+
1190
+ It is invisible in review because every number is defensible — the gap is right, the rungs are
1191
+ right, the control is the documented size — and the defect is the interaction between them. It is
1192
+ also the harder half of *same shape, same treatment*: the reason for the difference is real, and a
1193
+ reader cannot see it.
1194
+
1195
+ Pin the row to the TEXT's line box (`height` = the rung's leading) and let the control overflow it.
1196
+ The hit area is unchanged — it paints outside the box rather than growing it — and the text sits
1197
+ where it would if the control were not there. The same applies to any verb riding a value: a
1198
+ `CopyButton`, an `InlineButton`, a count chip.
1199
+
1200
+ ## Identity marks — a mark that is the same on every row carries nothing
1201
+
1202
+ `Avatar` derives its ground from the NAME (`avatar_tone`), and takes `shape="circle" | "square"`
1203
+ — a person or an organization.
1204
+
1205
+ **The shape varies only where the surface MIXES entity types** and the reader acts on which is
1206
+ which: a search result holding both people and companies, a "related records" list, a picker over
1207
+ two directories. On a surface whose rows are all one kind of thing — a register of records, a
1208
+ roster, a list of accounts — pick ONE shape and hold it. The distinction is real, but a reader
1209
+ does not act on it there, and a column of mixed circles and squares reads as an inconsistency
1210
+ rather than as information. That is the general form: a structural device asserts a difference,
1211
+ and asserting one the reader cannot use costs more than it gives.
1212
+
1213
+ Both exist for the same reason: the mark's entire job is making a row identifiable at a glance,
1214
+ so it is the one element in the row that must not look the same on every row. A register of
1215
+ twenty people in one accent colour is twenty identical circles, with the largest and most
1216
+ colourful thing in the row carrying no information; and a register of companies drawn with a
1217
+ person's disc asserts they are the same kind of entity, which the column header alone has to
1218
+ correct.
1219
+
1220
+ - **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
1221
+ the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
1222
+ on every screen and in every session — the value is never stored.
1223
+ - **A palette of identity hues is ordered by CONTRAST, not by spectrum.** Listed by hue, six
1224
+ consecutive entries are six shades of one purple, and any two names hashing to adjacent buckets
1225
+ look identical. Interleave. This is invisible to every count-based test — a hash returning seven
1226
+ distinct buckets still renders six neighbouring purples — so it is checked by eye, on real
1227
+ names from the language the app actually runs in.
1228
+ - **A person is never `red` or `amber`.** Those mean danger and waiting everywhere else, and a
1229
+ palette that can paint someone red makes every red on the screen ambiguous.
1230
+ - **Every register row should carry a LEADING MARK, best effort from the data.** An avatar, a
1231
+ logo, a thumbnail, a file badge, a coloured initial — something the eye can aim at without
1232
+ reading. A register whose rows open with text starts flat, and the symptom is reported as
1233
+ anything but the cause ("that avatar looks too small", "it looks plain"), because a missing
1234
+ element is invisible by definition. A person gets initials; an account gets the squared mark; a
1235
+ record with no likeness of its own borrows the identity a reader actually scans for — a deal
1236
+ takes its account's mark, since "whose deal is this" is the question being asked of the list.
1237
+ Only rows with genuinely no identity are exempt: a list of amounts, a log of events.
1238
+ - **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
1239
+ where it is one field among columns (see `avatar_size`).
1240
+
1241
+ The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
1242
+
1243
+ ## Character comes from the DATA — the answer when a screen reads "bland"
1244
+
1245
+ Every rule above removes a defect. None of them ADDS anything, so a screen can pass all of them
1246
+ and still be reported as plain, boring, or unfinished — which is a different complaint with a
1247
+ different fix. Removing defects converges on *inoffensive*; these three questions converge on
1248
+ *distinctive*.
1249
+
1250
+ - **What is inherently visual in THIS data?** Work software has no photography, so the data itself
1251
+ is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
1252
+ bars, tabular figures aligning down a column. Every one of these is information first and
1253
+ decoration never — which is why they are the only kind of visual interest that survives contact
1254
+ with a real dataset. Reaching instead for a gradient, an illustration or a hero band adds weight
1255
+ without adding a single fact.
1256
+ - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
1257
+ view: it hands the reader the same undifferentiated wall the query returned. A screen with a
1258
+ point of view puts what needs attention first (`TableGroup`), spends its one accent on where the
1259
+ reader is, and lets everything else recede. This is usually the real answer when treatment fixes
1260
+ keep not landing.
1261
+ - **What makes this belong to THIS product?** Default typeface, default greys, default accent,
1262
+ default control shapes is a *well-built generic screen*, and no further polish changes that. The
1263
+ fix at that point is a theme (`applyLoticsTheme`), not another treatment pass — and knowing
1264
+ which of the two you are looking at saves a great deal of pointless nudging.
1265
+
1266
+ Type SCALE is the fourth, and it belongs with the others: range = largest ÷ smallest across the
1267
+ screen, and under about 2× reads flat *at any amount of colour*. Scale is the one hierarchy device
1268
+ colour cannot substitute for. A register with no page band tops out near 1.33×.
1269
+
1270
+ ## Where the accent goes — "where you are", and nothing else
1271
+
1272
+ `colors.accent` paints the state that answers **where the reader is** on a SURFACE: the selected
1273
+ row's ground (`accent_wash`), an active filter. It never paints DATA — a status, a series and a
1274
+ valence all come from a palette family, so an accent can never overwrite a meaning — and it never
1275
+ paints the focus ring.
1276
+
1277
+ **A page-scale position marker takes `primary`, not `accent`** — an active tab's underline is the
1278
+ case. `primary` is already the ink the kit spends on "this is on": a checkbox's fill, a switch's
1279
+ track, a picked option's tick, the one primary button. An active tab is the same claim at page
1280
+ scale, so it belongs to the same ink, and the rail then sits at the same weight as the CTA it
1281
+ shares a screen with. Painted in `accent`, an unthemed screen gets exactly one blue thing on it —
1282
+ a single-member hue from a family used nowhere else, which is the definition of a library default
1283
+ nobody chose (see § Color discipline), three pixels from a near-black button.
1284
+
1285
+ The line between the two: `accent` marks WHICH OF THESE (a row among rows, a filter among
1286
+ filters), `primary` marks THIS IS ON (a control's state, the section you are in).
1287
+
1288
+ `accent_wash` is derived from `accent`, not chosen beside it. The two used to default to blue-600
1289
+ and zinc-100 — one token pair disagreeing with itself, so every app that themed nothing got a
1290
+ colourless "you are here".
1291
+
1292
+ **Selection and hover must not share a wash.** They did, and the register's one piece of
1293
+ persistent state was erased by a transient one: the row whose record is open looked exactly like
1294
+ the row under the pointer. Hover is neutral and lighter (it answers "you can press this"),
1295
+ selection carries the accent.
1296
+
1297
+ ### Theming is an app-level ESCAPE HATCH, not a product surface
1298
+
1299
+ An app sets its own identity in its own code — `applyLoticsTheme({ … })` once at boot, before
1300
+ `mount`. **Nothing in the platform writes a theme**,
1301
+ there is no settings screen for it, and `apps.theme.color` is the LAUNCHER ICON's colour and
1302
+ nothing else. Read that as the boundary rather than as a gap waiting to be filled: the mechanism
1303
+ exists because a customer occasionally needs their own typeface, not because theming is a feature
1304
+ we sell.
1305
+
1306
+ **What makes it possible is small and load-bearing.** `colors.web.ts` reads five roles through a
1307
+ CSS variable with the kit's own value as the inline fallback, and `font_family.web.ts` does the
1308
+ same for the three weight rungs. That indirection is the ENTIRE mechanism — and it cannot be
1309
+ replaced by wrapping a component and passing styles down, which is the intuition to check before
1310
+ reaching for it. React Native Web writes `fontFamily` onto every `Text` element itself, so a
1311
+ wrapper's font has nothing to inherit into; and `background-color` and `border-color` do not
1312
+ inherit in CSS at all, so no ancestor can reach a `Button`'s fill or a `Table`'s hairlines. The
1313
+ only alternative to the variables is passing `style` at every call site, which is a fork rather
1314
+ than a theme.
1315
+
1316
+ **Prefer `applyLoticsTheme` over declaring the variables by hand**, because two of the values are
1317
+ DERIVED and getting either wrong fails quietly: `accent_wash` is the accent at 7%, and one
1318
+ `bodyFont` has to reach all three weight rungs or the medium and semibold runs stay in the kit's
1319
+ own face and the screen reads as a font that half-loaded.
1320
+
1321
+ **It is a function, not a provider**, and the shape follows from what theming IS: an app declares
1322
+ one identity at boot and never changes it. A React component bought a memo, a wrapper element, a
1323
+ layout effect, a restore-on-unmount and a context nobody read — all of it lifecycle for a value
1324
+ with no lifecycle. Calling it before `mount` is also strictly better than a provider could be: the
1325
+ variables are in place for the FIRST paint, where a provider's effect ran after one.
1326
+
990
1327
  ## Color discipline — solid / tint / ramp + ONE accent
991
1328
 
992
1329
  Every status / data-viz / accent color comes from a NAMED helper (`@lotics/ui/colors`), never a
@@ -1003,6 +1340,13 @@ accent per screen purpose** (blue = pipeline, emerald = money, red = danger, amb
1003
1340
  Direct `colors.*` access is reserved for NEUTRALS (`zinc`, `border`, `white`, the `*[50]`
1004
1341
  selection washes).
1005
1342
 
1343
+ **Count the chromatic grounds on a finished screen — the tally is the check.** One or two hues used
1344
+ many times reads calm and deliberate; ZERO reads like a spreadsheet; ten used once each reads
1345
+ cheap. A hue with a SINGLE member, from a family used nowhere else on the screen, is a library
1346
+ default nobody chose — the giveaway is that the call site mentions no colour at all, so the shade
1347
+ arrived from a component's fallback rather than from a decision. Either give it siblings or take it
1348
+ back to a neutral.
1349
+
1006
1350
  **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
1007
1351
  `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
1008
1352
  (emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
@@ -1069,6 +1413,22 @@ them on a shared baseline — the symptom is usually reported as "the alignment
1069
1413
  The check is cheap and worth running whenever a component takes a size: render the small rung and
1070
1414
  measure the TEXT inside it, not the box. If the text did not move, the prop is only half wired.
1071
1415
 
1416
+ **SMALL is not QUIET.** When a label reads wrong, check whether someone made it smaller *and*
1417
+ fainter to make it recede — that trades legibility for quiet, and it usually causes the very
1418
+ complaint it was meant to fix, because a reader who cannot read a label reports the block as
1419
+ cluttered rather than as small. Recede with INK, at the same rung. `xs` is for genuine meta (a
1420
+ timestamp, a count), not for a label that a person actually has to read.
1421
+
1422
+ **A LABEL sharing a row with a flexible VALUE must be told which one gives way.** Two items in one
1423
+ flex row, and whichever cannot shrink forces the other to. A value floored at its longest
1424
+ unbreakable word leaves the label absorbing every remaining pixel — collapsing to a
1425
+ one-character-wide column many times taller than the row it names. It is invisible wherever the
1426
+ values happen to fit, which is usually the width it was built at, and it appears the first time a
1427
+ real record carries a long one. Decide deliberately: `flexShrink: 0` on the one that must stay
1428
+ whole (a fixed-length key, a label), `flexShrink: 1` + `numberOfLines` on the one that may clip,
1429
+ and pick by which is still USEFUL truncated — `RC-2026-0…` is unusable, `(555) 384-7…` is still
1430
+ recognisably a phone number.
1431
+
1072
1432
 
1073
1433
 
1074
1434
  Only the `Text` primitive (`size` / `weight` / `color` / `transform` / `tabular` / `level` for
@@ -1076,7 +1436,7 @@ semantic heading rank). Uppercase tracking is built into `transform="uppercase"`
1076
1436
  `letterSpacing`.
1077
1437
 
1078
1438
  On web, the heading sizes are **responsive** at the 768px viewport breakpoint (the kit's screen-size
1079
- boundary); native uses the base column:
1439
+ boundary); native uses the base column, because a phone is a phone:
1080
1440
 
1081
1441
  | `size` | base / native | web ≥ 768px | Role |
1082
1442
  |---|---|---|---|
@@ -1088,7 +1448,22 @@ boundary); native uses the base column:
1088
1448
  | `xxl` | 28 | 32 | `#` page/record title |
1089
1449
  | `xxxl` | 32 | 48 | hero numbers |
1090
1450
 
1091
- (Line heights are baked per size and platform-tuned; don't override them.) Weights: `regular` / `medium` /
1451
+ **Leading and tracking are baked per rung never set either by hand.** Both curves live in
1452
+ `type_ramp.ts`, and the rungs carrying running text sit at PROSE leading (14/24) so a typed note
1453
+ and the same note rendered as markdown read at one rhythm. Tracking is negative at every rung and
1454
+ steepens as the type grows — nothing in the kit tracks positive except `transform="uppercase"`,
1455
+ which compensates for the caps itself.
1456
+
1457
+ A hand-set `letterSpacing` is a finding wherever it appears, and it fails in a specific way: it
1458
+ looks like a local decision and is actually a second copy of the curve. Three of them shipped
1459
+ before this was written — a private size→tracking table on `Metric` that put the product's
1460
+ summary figures at ZERO while the sentence beside them tracked -0.48px, a flat value on `Text`
1461
+ itself that was the whole of native's tracking, and `+0.3` left on a column header after its size
1462
+ and weight had been corrected. Each was defensible in its own file and wrong on the screen. If
1463
+ something cannot be wrapped in `Text` (a raw `TextInput`, a chart axis), take the rung from
1464
+ `type_ramp` rather than picking a number.
1465
+
1466
+ Weights: `regular` / `medium` /
1092
1467
  `semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
1093
1468
  (`danger`/`warning`/`success`), and the explicit `zinc-900/700/500/400` steps (`zinc-400` is the
1094
1469
  disabled ink).
@@ -1193,6 +1568,17 @@ that praises it is marketing.
1193
1568
  a middot is usually a substitute for. Facts of two different KINDS want two lines, not one
1194
1569
  line with a glyph between them. (Verbatim quoted DATA keeps its own delimiters — this governs
1195
1570
  copy we write, not text we reproduce.)
1571
+ - **STRUCTURE MUST ENCODE SOMETHING TRUE.** Every structural device is an assertion, and a false
1572
+ one is decoration that costs more than it gives. A rule asserts *these are separate*; an eyebrow
1573
+ asserts *this labels what follows*; a number asserts *there is an order the reader needs*; a
1574
+ badge asserts *this is a state*; a colour asserts *this differs in kind from its neighbour*.
1575
+ Numbered markers on a set nobody traverses in order, a divider between two things that belong
1576
+ together, a coloured pill on a city — each reads as information and carries none, and the cost
1577
+ is paid by the devices that ARE telling the truth, which get discounted along with it.
1578
+ - **One job per element.** A label labels, an example demonstrates, a caption qualifies. Nothing
1579
+ quietly does double duty — and a SHORTENED element still has to justify existing, because
1580
+ trimming makes it pass every length check while leaving the question of whether it earns its
1581
+ place unanswered.
1196
1582
  - **Plain language over jargon; effect over mechanism** ("Record visibility", not "Row-Level
1197
1583
  Privacy") — if a term needs explaining, replace it. **One concept = one word everywhere** —
1198
1584
  pick the term once and reuse it on every surface.
@@ -75,6 +75,31 @@ message on a `touched` flag set `onBlur`, keep the commit gated on the real vali
75
75
  showing error clear live as the value becomes valid — the shape [`useForm`](../src/use_form.ts)
76
76
  already has: errors arrive from a validate pass, and editing a field clears that field's error.
77
77
 
78
+ ## A picker over a select FIELD keeps the field's colours — `optionPicker`
79
+
80
+ <InlineSelect {...optionPicker(fields.status?.options ?? [])}
81
+ value={statusKey} onSave={(v) => save({ status: v })} />
82
+
83
+ Spread it into `InlineSelect`, `Select`, `OptionList` or `Combobox` — it supplies both the
84
+ `options` and the `renderOptionContent` that paints each one.
85
+
86
+ **The obvious call site throws the colour away, silently.** A picker takes `{ value, label }`, so
87
+ every app writes `options.map((o) => ({ value: o.key, label: o.label }))` — and `color` is not in
88
+ that shape, so it never arrives. Nothing errors and nothing looks broken; the value simply renders
89
+ as a coloured chip in the register and as bare grey text the moment someone goes to CHANGE it,
90
+ which is backwards. Recognition matters most in the list you are choosing from, and a filter
91
+ popover of identical grey rows makes the reader match text where they could have matched a colour.
92
+ It is worth grepping for: the mapping is boilerplate, it looks correct, and one CRM had thirteen
93
+ of them.
94
+
95
+ The mapping and the renderer ship together deliberately. Returning only the options would leave
96
+ each caller to write the `renderOptionContent` that reads `data`, and an app that mapped correctly
97
+ but forgot the renderer lands back on grey text with nothing to indicate why.
98
+
99
+ Default `dot`: a picker row is a place to READ a set of choices, and a stack of filled pills is
100
+ heavier than the list describing them. Pass `{ variant: "tonal" }` where the selected value is the
101
+ surface's one prominent status.
102
+
78
103
  ## Inline edit — the preferred way to edit an existing record
79
104
 
80
105
  When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a