@lotics/ui 45.10.0 → 45.10.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.
@@ -34,9 +34,7 @@ text box — and on a record surface radio/checkbox render as PERSISTENT control
34
34
  is the best display of the value; prose-shaped values and registry picks stay inline
35
35
  editors). **A MARKDOWN-typed field takes `InlineMarkdown`**, never `InlineTextInput`: the
36
36
  plain input prints the syntax it stores, so a reader sees `**` where every other surface
37
- showing that value renders it. It hides in the gap between two states of one field the
38
- value is formatted wherever it is read-only and raw only where it is EDITABLE, which is one
39
- row, and looks like a quirk of that row rather than the wrong control. Rich `InlineSelect` options carry a description line (`renderOptionContent` — it
37
+ showing that value renders it. Rich `InlineSelect` options carry a description line (`renderOptionContent`it
40
38
  shows in the resting row too; the `data` generic types option payloads). A DEPENDENT field
41
39
  renders only while its parent value makes it real — never a disabled ghost row. Worked rows:
42
40
  `tpl_record`'s Classification group.
@@ -57,28 +55,25 @@ definitionally the second** — its Save/Create is the commit — so build it fr
57
55
  `FormTextInput` with controlled `value` + `onChangeText`, even when the record surface behind it
58
56
  is a grid of inline editors.
59
57
 
60
- This is **not** a safety rule, and it does not soften the press-gate law below ("Commit-on-blur
61
- would race the press that caused it"): inline editors in a dialog lose nothing, because `Button`
62
- already holds its press until the blur's commit settles. The gate makes the press SAFE. What it cannot do is make the dialog HONEST
63
- while the field still has focus it only acts at press time, and everything **derived** from the
64
- draft trails the typing until then: a validity `error`, a Save gated on that validity, a computed
65
- total. Type a valid value and the form still says invalid, and its Save still sits disabled, until
66
- focus leaves. That is the dialog arguing with the box the reader is looking at, and no amount of
67
- press-gating answers it, because the reader is not pressing anything yet.
58
+ Inline editors in a dialog lose nothing, because `Button`
59
+ already holds its press until the blur's commit settles. The gate makes the press SAFE. What it
60
+ cannot do is make the dialog HONEST while the field still has focus everything **derived** from
61
+ the draft trails the typing until then: a validity `error`, a Save gated on that validity, a
62
+ computed total. Type a valid value and the form still says invalid, and its Save still sits
63
+ disabled, until focus leaves.
68
64
 
69
- Controlled inputs make derived state track what is on screen, and read shorter than the editors
70
- they replace — four `DetailRow` + `Inline*` pairs whose `onSave` exists only to `setState` collapse
71
- to four fields. Worked example: `tpl_record`'s New-customer dialog.
65
+ Controlled inputs make derived state track what is on screen. Worked example: `tpl_record`'s
66
+ New-customer dialog.
72
67
 
73
68
  **The discriminator, since the kit's own `tpl_item_list` fills preview cards with `Inline*`
74
69
  editors:** what decides is whether anything on the surface READS the draft before the commit. A
75
70
  dialog whose Save is gated on the values being typed, or whose visible totals derive from them,
76
71
  takes controlled `FormField`s — that is this rule. A REVIEW surface whose gate is decided by
77
- something else (which cards are kept, which candidate is picked) may keep inline editors, because
78
- nothing is arguing with the box under the cursor. Neither shape is a create form's stand-in.
72
+ something else (which cards are kept, which candidate is picked) may keep inline editors. Neither
73
+ shape is a create form's stand-in.
79
74
 
80
- **But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite, and the
81
- opposite failure to the one above. Validity and the error MESSAGE are different questions. Validity
75
+ **But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite.
76
+ Validity and the error MESSAGE are different questions. Validity
82
77
  gates the commit and is checked continuously; the message claims the reader did something wrong, so
83
78
  it may only be made once they are DONE. Every prefix of a correct fixed-length value is invalid, so
84
79
  a per-keystroke message turns the first digit of a right answer into a red complaint. Gate the
@@ -88,28 +83,28 @@ already has: errors arrive from a validate pass, and editing a field clears that
88
83
 
89
84
  ## A picker over a select FIELD keeps the field's colours — `optionPicker`
90
85
 
91
- <InlineSelect {...optionPicker(fields.status?.options ?? [])}
86
+ <InlineSelect {...optionPicker(fields.city?.options ?? [])} // a category
87
+ value={cityKey} onSave={(v) => save({ city: v })} />
88
+ <InlineSelect {...optionPicker(fields.status?.options ?? [], { badge: "dot" })} // a status
92
89
  value={statusKey} onSave={(v) => save({ status: v })} />
93
90
 
94
- Spread it into `InlineSelect`, `Select`, `OptionList` or `Combobox` — it supplies both the
95
- `options` and the `renderOptionContent` that paints each one.
91
+ Spread it into `InlineSelect`, `Select`, `OptionList` or `Combobox` — it supplies the `options`
92
+ (each carrying the field's own option as `data`) and, when you ask for a badge, the
93
+ `renderOptionContent` that paints each one.
96
94
 
97
95
  **The obvious call site throws the colour away, silently.** A picker takes `{ value, label }`, so
98
96
  every app writes `options.map((o) => ({ value: o.key, label: o.label }))` — and `color` is not in
99
97
  that shape, so it never arrives. Nothing errors and nothing looks broken; the value simply renders
100
98
  as a coloured chip in the register and as bare grey text the moment someone goes to CHANGE it,
101
- which is backwards. Recognition matters most in the list you are choosing from, and a filter
102
- popover of identical grey rows makes the reader match text where they could have matched a colour.
103
- It is worth grepping for: the mapping is boilerplate, it looks correct, and one CRM had thirteen
104
- of them.
99
+ which is backwards. It is worth grepping for: the mapping is boilerplate and it looks correct.
105
100
 
106
- The mapping and the renderer ship together deliberately. Returning only the options would leave
107
- each caller to write the `renderOptionContent` that reads `data`, and an app that mapped correctly
108
- but forgot the renderer lands back on grey text with nothing to indicate why.
109
-
110
- Default `dot`: a picker row is a place to READ a set of choices, and a stack of filled pills is
111
- heavier than the list describing them. Pass `{ variant: "tonal" }` where the selected value is the
112
- surface's one prominent status.
101
+ **The badge is OPT-IN `{ badge: "tonal" | "dot" }` — and that is the design.** Omit it and
102
+ `renderOptionContent` comes back `undefined`, so the picker falls back to its own label rendering
103
+ and the options read as plain text: correct for a type, a category or an attribute (an industry, a
104
+ source, a department, a city), where badging every value spends the vocabulary that made a coloured
105
+ pill mean something. A field carrying a `color` is not consent to paint it. Pass it where the value
106
+ is a STATUS `dot` by default, `{ badge: "tonal" }` where the selected value is the surface's one
107
+ prominent status.
113
108
 
114
109
  ## Inline edit — the preferred way to edit an existing record
115
110
 
@@ -129,44 +124,38 @@ resting value is the RAW string (`InlineTextInput`) is ONE `<input>` in both sta
129
124
  swaps elements, and `editing` is a style state driven by focus. Two elements cannot be made to
130
125
  draw the same string identically: matching the padding, the border and the ink still leaves an
131
126
  `<input>` centring by FONT METRICS where a `<div>` positions by LINE BOX, a quarter-CSS-pixel
132
- residual that no property closes, is invisible to `getBoundingClientRect` (it reports the rounded
133
- box), and lands on a real device pixel at fractional scaling. Where the resting display is
127
+ residual that no property closes, that is invisible to `getBoundingClientRect` (it reports the
128
+ rounded box, and both paths compute the same text top), and that still lands on a real device
129
+ pixel. Where the resting display is
134
130
  FORMATTED and the editor shows something else (`1,250 kg` → `1250`, `31/07/2026` → a segmented
135
131
  field), the string changes anyway, so those editors do swap and should. **The rule for a new
136
132
  editor: same string in both states ⇒ one element; different string ⇒ swap.**
137
133
 
138
134
  Two worked cases sit on either side of it, and they look alike until you apply the rule.
139
- **`InlineMarkdown` does NOT swap, and the reason it tried to is the lesson.** Its editor is the
135
+ **`InlineMarkdown` does NOT swap.** Its editor is the
140
136
  WYSIWYG `MarkdownEditor`, so both states show rendered prose — the rule's first limb, one element.
141
- It was nonetheless built as a swap on a COST argument (a ProseMirror view per field is not free the
142
- way an `<input>` is), and every defect that followed came from that one decision: the box changed
143
- height on open, a toolbar rolled in and pushed the form down, and the resting state was a `Pressable`
144
- — so it wore the pointer cursor and `userSelect: none` denied a reader the ability to select a
145
- sentence out of their own note.
146
-
147
- None of those are fixable while two elements draw one value; a measured height floor only papers
148
- over the first. **When "the layout must not move" is a requirement rather than a preference, one
137
+ A ProseMirror view per field is not free the way an `<input>` is.
138
+
139
+ **When "the layout must not move" is a requirement rather than a preference, one
149
140
  element is the only thing that delivers it** — a swap can be made to look stable, never to be it. So
150
141
  the editor is mounted at rest and gains a draft on focus, and the per-field cost is paid on purpose.
151
142
  If a screen ever holds dozens of these, the answer is fewer markdown fields on it, not a swap.
152
143
 
153
144
  **`variant` has to reach whatever actually DRAWS the box.** `InlineEditFrame` paints no surface on
154
145
  the path this field takes — it has no `actions` — so the editor's own frame is the field's frame,
155
- and a `variant` that stops at the wrapper stops at nothing: `bare` bought no change at all, and
156
- every markdown field wore the document sheet whatever the caller asked for. So `MarkdownEditor`
146
+ and a `variant` that stops at the wrapper stops at nothing. So `MarkdownEditor`
157
147
  takes the same `"framed" | "bare"` axis as `TextInputField`, and `InlineMarkdown` passes it through.
158
148
 
159
149
  **The half that is easy to miss is the INSET, not the border.** A document sheet insets its prose
160
150
  further than a control does (13px against `CONTROL_TEXT_INSET`'s 9), so an editor serving as a
161
151
  field lands 4px right of the plain-text field stacked directly above it — a ragged left edge inside
162
- one card, with nothing on screen naming the cause. A field variant is therefore what makes the
152
+ one card. A field variant is therefore what makes the
163
153
  editor a FIELD: it takes the control's corner (`CONTROL_RADIUS`) and the control's inset with it.
164
154
  The third value, `"document"`, is the default and every standalone surface in the product — and it
165
155
  is a NAMED value rather than an absence, because an optional prop whose omission is a distinct
166
156
  third mode reads backwards to anyone who knows `TextInputField.variant`, where omitting it gives
167
- you `"framed"`. `seamless` is the different case and not this one: that surrenders the surface to a
168
- host that draws it, and here there is no host — `InlineEditFrame` paints one only when it also owns
169
- the verbs, so with none the CHILD owns the surface.
157
+ you `"framed"`. `seamless` is the different case: that surrenders the surface to a
158
+ host that draws it, and here there is no host.
170
159
  **A LINK does not swap**: marking a URL changes its ink, not its characters, so `InlineTextInput`
171
160
  takes a **`link`** treatment on its single `<input>` rather than swapping in a `TextLink`. Pressing
172
161
  the field still edits — an `Open` `InlineButton` in `actions` is how you reach the destination,
@@ -203,14 +192,14 @@ Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a
203
192
  SWAP editor — `InlineNumberInput`, `InlineDatePicker` — opens edit mode
204
193
  immediately with the input focused; commit-on-blur then makes Tab itself the commit, so the chain
205
194
  is type → Tab → type with the next editor already open. Pointer focus never auto-opens (mousedown
206
- records "pointer" modality before focus fires — `interaction_modality.ts`), so the click path is
207
- exactly what it always was. `InlineTextInput` needs none of this — its input is always mounted, so
195
+ records "pointer" modality before focus fires — `interaction_modality.ts`). `InlineTextInput`
196
+ needs none of this — its input is always mounted, so
208
197
  focus IS the edit by either route. When a **keyboard** close (Enter/Escape) leaves focus on
209
198
  `<body>`, a swap editor returns focus to its resting view so the next Tab continues from the
210
199
  field (the always-mounted one never loses focus, so there is nothing to restore). A **pointer**
211
- close (click ✓/✕, or click away onto non-focusable space) does NOT restore — a mouse user has no
212
- next Tab to preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
213
- restore is gated on modality via `shouldRestoreFocusOnClose`).
200
+ close (click ✓/✕, or click away onto non-focusable space) does NOT restore — a bare `.focus()`
201
+ would scroll the target into view, jerking the page (the restore is gated on modality via
202
+ `shouldRestoreFocusOnClose`).
214
203
 
215
204
  **Typed dates.** `InlineDatePicker`'s keyboard mode is an internal segmented date field
216
205
  (`DateField` — not exported; reached only through `InlineDatePicker`): type
@@ -218,8 +207,8 @@ the date in the locale's own field order — dd/MM/yyyy under the `vi` provider,
218
207
  under `en`, derived from the active `LoticsLocaleProvider` (an explicit `locale` prop still
219
208
  overrides) — digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
220
209
  day/month; Enter or blur commits, Escape reverts, Alt+ArrowDown floats the calendar. The
221
- calendar popover stays the pointer path (click the resting value, as ever) it is no longer
222
- the only path. A PARTIAL entry never commits and never clears the stored value: the field
210
+ calendar popover stays the pointer path (click the resting value). A PARTIAL entry never commits
211
+ and never clears the stored value: the field
223
212
  stays in edit mode showing the `datePicker.invalidDate` inline error until fixed or Escaped.
224
213
 
225
214
  The POPOVER editors (`InlineSelect` / `InlineMemberSelect`) deliberately
@@ -236,9 +225,9 @@ One per type:
236
225
  default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label.
237
226
  An option ROW may be taller than one line — a rich row (custom `renderOptionContent`, or a
238
227
  label over a `getOptionDescription` line) renders as `MenuListItem`, the listbox row that
239
- GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short
240
- code + the full official name underneath is a supported option shape, not a hack. Reach for it
241
- whenever the real name is too long to READ in a list but must stay exact somewhere.
228
+ GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short code
229
+ with the full official name underneath is a supported option shape reach for it whenever the
230
+ real name is too long to READ in a list but must stay exact somewhere.
242
231
  **The popover is the FIELD's width** (floored at `MIN_CONTROL_WIDTH` so a dense grid cell still
243
232
  opens something readable) — shared by `Select` and `Combobox`, every popover anchored to a
244
233
  field. So option text longer than the field WRAPS; it never widens the panel, and a paragraph-
@@ -295,19 +284,17 @@ something belongs elsewhere.
295
284
 
296
285
  It is wrong wherever the length is the AUTHOR's choice, and it fails in the worst available way.
297
286
  The box is drawn at the budget and the value simply exceeds it: no ellipsis, no clamp, no
298
- scrollbar, nothing to scroll — a truncation mark would have been kinder; this is silent. So the test is not "is this field long" but **"who decides the
287
+ scrollbar, nothing to scroll. So the test is not "is this field long" but **"who decides the
299
288
  length"** — the field, or whoever is typing.
300
289
 
301
290
  **And it gets worse as the surface narrows**: a budget is a count of LINES and the wrap point
302
291
  moves, so the narrower column wraps the same value into more hidden lines. A fixed reserve
303
292
  verified on a wide screen is not verified.
304
293
 
305
- `autoGrow` makes the budget a minimum and fits the value. It costs nothing the fixed reserve was
306
- protecting, and the reason is worth stating because it is easy to assume otherwise: an
307
- `InlineTextInput` is ONE input in both states, never a resting view swapped for an editor, so a
308
- grown box is the same height focused as at rest. The reserve was guarding against a jump the
309
- one-element design had already made impossible. Growing on a control that DOES swap (number, date, time — their resting display is
310
- formatted) would reintroduce it, which is why the prop lives here and not on those.
294
+ `autoGrow` makes the budget a minimum and fits the value. An `InlineTextInput` is ONE input in
295
+ both states, so a grown box is the same height focused as at rest. Growing on a control that DOES
296
+ swap (number, date, time their resting display is formatted) would reintroduce the jump, which
297
+ is why the prop lives here and not on those.
311
298
 
312
299
  Two places to keep the reserve anyway: a value that can be **arbitrarily long** (growth has no
313
300
  ceiling — 406 characters is already 238px, so a field that might hold a document wants the budget,
@@ -319,8 +306,8 @@ on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_e
319
306
  ### The row stack — `DetailTable` + `DetailRow`
320
307
 
321
308
  A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
322
- `minHeight` set ONCE on the parent, plus the `SPACE.md` row gap bordered fields need — a border
323
- is a harder edge than the old tint, so rows 8px apart fused) holding `DetailRow`s. There are TWO columns and no third: the value FILLS whatever the label leaves, so
309
+ `minHeight` set ONCE on the parent, plus the `SPACE.md` row gap bordered fields need) holding
310
+ `DetailRow`s. There are TWO columns and no third: the value FILLS whatever the label leaves, so
324
311
  nothing one row does can narrow its neighbours' editors. Rows share ONE alignment law: the row
325
312
  top-aligns and label, control each center within the first control line — annotations (the three tones
326
313
  under the value: `description` muted, `warning` amber, `error` danger, each indented to the
@@ -333,29 +320,26 @@ tooltip to recover the tail; the wrapped label's first line stays level with the
333
320
 
334
321
  An editor AT REST wears THE pill surface — WHITE with a 1px border, the same one `Chip`,
335
322
  `ChipGroup` and a secondary `Button` wear. That is THE editability affordance: a read-only
336
- `InlineStatic` stays flat and borderless, so users see what is editable without hovering, and a
337
- bordered box beside bare text is a plainer signal than a tint. Hover marks the field you are about to act
338
- on with a CHANGE you can see, which is why it differs by variant: a `framed` field deepens its
339
- border AND tints (border alone would be zinc-200 zinc-400, a shade shift on a 1px line), while a
340
- `bare` field has the border ARRIVE out of nothing, which is loud enough by itself and a tint
341
- there would make a hovered cell lighter than the row washing under it. Open adds the 2px ring. A `disabled` editor rests FLAT and borderless
323
+ `InlineStatic` stays flat and borderless, so users see what is editable without hovering. Hover
324
+ marks the field you are about to act on with a CHANGE you can see, and it is ONE signal in both
325
+ variants: the border darkens to `HOVER_BORDER` (zinc-500) a `framed` field deepens the border it
326
+ already has, a `bare` field has that border ARRIVE out of nothing. **Nothing tints.** A hover wash
327
+ on top would make inline fields the one control in the kit with a second hover language, and a
328
+ reader crossing a record surface would meet both. Open adds the 2px ring. A `disabled` editor rests FLAT and borderless
342
329
  automatically — the surface is the editability promise, and an inert field must not make it.
343
330
 
344
331
  **`variant` decides how much of that frame shows AT REST — an axis of WEIGHT, not of use.**
345
332
  `"framed"` (default) keeps the surface visible: required wherever editable and static values MIX,
346
333
  because the frame is the only thing saying which values you can change. `"bare"` shows nothing at
347
334
  rest and fades the SAME border in on hover — for a surface where EVERY value is editable (a
348
- register/`DataGrid` column, a task row), where a per-field frame states what the whole surface
349
- already promises and, repeated down a column, draws the grid a second time. Both hover and open
335
+ register/`DataGrid` column, a task row). Both hover and open
350
336
  identically, so this changes what a field looks like RESTING, never what it does. A hand-rolled
351
337
  pressable cell should match `bare`: nothing at rest, the border on hover — never a background wash.
352
338
 
353
339
  **A `bare` field aligns its TEXT, not its box.** Because it looks like text at rest, it sits on the
354
340
  column like text: the frame's 8px inset is pulled back out with a negative LEFT margin, so the glyphs
355
341
  land on the container's edge. Without that, every bare value rendered 8px right of the header naming
356
- it. The bleed is one-sided on purpose a matching right bleed aligns nothing on left-aligned text and
357
- only pushes the frame past the edge its neighbours stop at, which shows the moment the border paints.
358
- Two consequences for callers: a `bare` field needs ~8px of slack on its LEFT to hover into (in a
342
+ it. The bleed is one-sided on purpose. Two consequences for callers: a `bare` field needs ~8px of slack on its LEFT to hover into (in a
359
343
  zero-gap column the borders of adjacent cells will touch), and it is the wrong variant for a surface
360
344
  where it must stay strictly inside its box — that is `framed`. A DISABLED `framed` field keeps the
361
345
  box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
@@ -364,16 +348,12 @@ fields beside it.
364
348
  **`bare` puts its whole affordance in the hover edge, so nothing may outrank that edge.** Pass the
365
349
  variant as a PROP; never express it as a resting `borderColor: "transparent"` in `style`, which is
366
350
  applied after the hover rule and erases the edge one line after it is computed. The failure is
367
- silent in every direction that usually catches things — the field keeps its role, its label, its
368
- focus ring, its metrics and its save — and what breaks is only that nobody presses it, because a
369
- control drawing nothing at rest and nothing on hover is text. The kit's own `TextInputField` had
370
- it, so a `bare` inline editor in a register column went un-pressed rather than mis-styled.
351
+ silent — the field keeps its role, its label, its focus ring, its metrics and its save — and what
352
+ breaks is only that nobody presses it, because a control drawing nothing at rest and nothing on
353
+ hover is text.
371
354
 
372
355
  **The cursor is the other half of that promise, and it belongs to the CONTROL, not to the page.**
373
- An inline editor takes the pointer like every other control. It once forced the arrow back, on the
374
- reasoning that an editor is an input rather than a button — right that it is not a button, wrong
375
- about the replacement: the arrow is what inert prose gets, so the one value on the row that was
376
- the reader's to change was the one that looked least touchable. The I-beam is not the alternative
356
+ An inline editor takes the pointer like every other control. The I-beam is not the alternative
377
357
  either; a resting editor sets `userSelect: "none"` so a drag edits rather than selects, and an
378
358
  I-beam would promise a caret that is not there. A field is disabled is the only case that reverts
379
359
  to the arrow, for the same reason it rests bare: an inert value must promise nothing.
@@ -385,8 +365,7 @@ glance and the static value never reads as a disabled input.
385
365
 
386
366
  **In a grid that MIXES the two, or one that ANNOTATES.** "Copies the editor box metrics" means it
387
367
  hard-sets `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight, waste where there is
388
- no column to hold. Two different things can make the column, and missing the second is what put
389
- three left edges on a review dialog:
368
+ no column to hold. Two different things can make the column:
390
369
 
391
370
  - **An editor beside it.** The obvious case: a static value rendered raw beside a real editor
392
371
  starts at the cell edge, 20px tall, at the top, while the editor's text starts 9px in and
@@ -401,8 +380,7 @@ values genuinely are short.
401
380
 
402
381
  **A `description` / `warning` / `error` under it.** `FieldAnnotations` insets its stack by
403
382
  `CONTROL_TEXT_INSET` so it lines up with a CONTROL's words — so an annotated row whose value is
404
- raw puts the value at the cell edge and its own description 9px inside it. Nothing else on the
405
- screen is wrong, the drift is small enough to read as sloppiness rather than structure, and it
383
+ raw puts the value at the cell edge and its own description 9px inside it and it
406
384
  appears only on the rows that happen to carry a description, so a column slides in and out of
407
385
  alignment down its own length.
408
386
 
@@ -424,8 +402,7 @@ exists to remove and pulls the description 4px too tight.
424
402
  ### Choosing a CHOICE control — by option count, not by taste
425
403
 
426
404
  A field whose value is one-of-N has three answers, and the wrong one is what makes a record
427
- surface look cluttered — not the number of rows. A row that is four rows TALL shouts over every
428
- neighbour, and variation in row WEIGHT reads as noise long before row COUNT does.
405
+ surface look cluttered — not the number of rows.
429
406
 
430
407
  | The choice | Reach for | Why |
431
408
  |---|---|---|
@@ -442,15 +419,13 @@ subject reached two ways (a password vs a mailed link — one account, one desti
442
419
  first earns a chooser. For the second, render the path most readers take and offer the other as a
443
420
  secondary action beside that form's submit, where it is visible without being in the way.
444
421
 
445
- The tell is that people press the chooser far more than once each — a real decision is made once
446
- and remembered, so repeat presses mean they are bouncing between doors rather than choosing. The
422
+ The tell is that people press the chooser far more than once each. The
447
423
  measurement is in [reviewing.md](./reviewing.md) probe 8b.
448
424
 
449
425
  And when you do offer the alternative beside the submit, **give it its own treatment**: an
450
- alternative WAY IN and a recovery link ("forgot your password") are different roles, and a reader
451
- parses shape before meaning, so two identical full-width buttons read as two equal ways to proceed
452
- however different their consequences. One primary, one secondary, and anything remedial drops to a
453
- quiet text link.
426
+ alternative WAY IN and a recovery link ("forgot your password") are different roles, and two
427
+ identical full-width buttons read as two equal ways to proceed however different their
428
+ consequences. One primary, one secondary, and anything remedial drops to a quiet text link.
454
429
 
455
430
  **"No selection" is the PLACEHOLDER, never an option whose value is the empty string.** A picker
456
431
  reads `""` as *nothing chosen* — it is what its own clear affordance emits back through
@@ -458,8 +433,7 @@ reads `""` as *nothing chosen* — it is what its own clear affordance emits bac
458
433
  ABSENT value (`value={x ?? undefined}` + `placeholder`), and map an incoming `""` back to that
459
434
  absence at the call site. An `{ value: "", label: "All types" }` option instead leaves the control
460
435
  with no selection AND no placeholder, so the trigger renders down to its chevron — a filter that
461
- still works, sized at a few pixels, which reviews as a rendering glitch rather than a modelling
462
- error.
436
+ still works, sized at a few pixels.
463
437
 
464
438
  **`SegmentedControl` is NOT a field control.** It belongs to the view-control vocabulary (a mode
465
439
  or parameter of the SAME view — see the composition grammar), and it fails as a value editor for
@@ -492,24 +466,20 @@ attachment grid edit in place too (below).
492
466
 
493
467
  ## A value rendered TWICE is the bug — not the resident editor
494
468
 
495
- The section above is the default and holds everywhere. There is one thing that appears to
496
- contradict it, and the contradiction is worth naming because the wrong resolution is the
497
- tempting one: **a value that already appears elsewhere on the same surface.**
469
+ The section above is the default and holds everywhere. One thing appears to
470
+ contradict it: **a value that already appears elsewhere on the same surface.**
498
471
 
499
- A feed used to be the example. Its row LABEL was the gist, the body opened underneath, and a
500
- resident editor rendered the same sentence twice stacked and identical, which reads as a
501
- mistake at any weight, so quieting the frame does not help. The fix looked like demoting the
502
- editor to a mode behind an **Edit** verb, and that is a workaround: it keeps the duplication and
503
- buys silence by hiding one copy behind a click. What it costs shows up immediately after —
472
+ A resident editor rendering the same sentence twice stacked and identical reads as a
473
+ mistake at any weight, so quieting the frame does not help. Demoting the
474
+ editor to a mode behind an **Edit** verb is a workaround: it keeps the duplication and
475
+ buys silence by hiding one copy behind a click, and
504
476
  the verb has to live somewhere (a button nested in the row's own button, or a footer a scroll
505
- away from the sentence it edits), and every repair from there is a repair to the arrangement,
506
- not to the surface.
477
+ away from the sentence it edits).
507
478
 
508
479
  **Render it once, and the question dissolves.** Text inside a press target cannot be edited where
509
480
  it sits, so a value a person WROTE does not belong in a row's label: the label is for derived,
510
481
  read-only text ("Stage changed to Won"). Move the prose out and it is an ordinary resident field
511
- with no mode, no verb, and one copy — `tpl_record`'s activity feed is the worked example, and the
512
- byline above it carries the derived facts that used to fight it for the row.
482
+ with no mode, no verb, and one copy — `tpl_record`'s activity feed is the worked example.
513
483
 
514
484
  So: if a resident editor would duplicate a value, **delete the duplicate**. Make the editor a mode
515
485
  only when the second appearance is genuinely load-bearing and cannot move.
@@ -522,9 +492,8 @@ Two things follow that are easy to get wrong in the other direction:
522
492
  verb, name it for the words rather than the row: "Edit", not "Edit entry", which claims the
523
493
  whole entry is yours to rewrite.
524
494
  - **An EMPTY value still shows its field, and shows it FRAMED.** A surface fed by automations
525
- receives entries with no words in them routinely a recording lands and nobody has written it
526
- up so withholding the field lets a feed RECEIVE an entry it gives the reader no way to
527
- finish. Empty is also the state where `bare` fails: grey placeholder text on an invisible box
495
+ receives entries with no words in them routinely, so withholding the field lets a feed RECEIVE
496
+ an entry it gives the reader no way to finish. Empty is also the state where `bare` fails: grey placeholder text on an invisible box
528
497
  does not read as somewhere you can type. Follow the value — `variant={value ? "bare" : "framed"}`
529
498
  — so a written-up entry reads as prose and an empty one is recognisable as an input from across
530
499
  the page. A button that reveals a field is the wrong trade: `InlineTextInput` has no
@@ -555,8 +524,8 @@ personal detail: `ComboboxInput`, `SearchInput`, `ScanField`, `ColumnFilter`, an
555
524
  `OptionList` search. Nothing to pass.
556
525
 
557
526
  It deliberately does NOT do this on `FormTextInput`, the inline editors, or the comment composer.
558
- A contact form SHOULD autofill a name, an email and a phone number suppressing it there is a real
559
- usability loss, and which fields those are is the app author's call, not the kit's. Pass
527
+ A contact form SHOULD autofill a name, an email and a phone number, and which fields those are is
528
+ the app author's call, not the kit's. Pass
560
529
  `autoComplete` yourself on a form field (`"name"`, `"tel"`, `"email"`, `"off"`); it forwards to the
561
530
  DOM input.
562
531
 
@@ -633,8 +602,7 @@ line asserts sequence, which is what a desk handoff IS. OFF for N items ticked i
633
602
  a line claims an order the work does not have. It is the same compound either way.
634
603
 
635
604
  **A GROUP is a name and nothing else.** No control: a phase owns no completion of its own, so a
636
- ring there reports without responding and one identical to the pressable rings beneath it
637
- invites the press it refuses, which is worse than showing nothing. No BODY: a condition or an act
605
+ ring there reports without responding. No BODY: a condition or an act
638
606
  hung off a heading leaves the reader working out which of the rows below it they were about. The
639
607
  row that OWES the work carries all of it — its wait (`meta`), its condition (`ChecklistNote`) and
640
608
  the act that leaves it (`ChecklistActions`).
@@ -643,15 +611,13 @@ the act that leaves it (`ChecklistActions`).
643
611
  on a dated ladder, today's date onto milestones that happened on days nobody recorded. Where the
644
612
  run is a SEQUENCE, derive position from the highest stamp instead — one click then marks
645
613
  everything below it, and a rung the record passed without a recorded day shows a BLANK date, which
646
- is the honest answer to "when" and stays one click from being filled. That is the whole of the
647
- bulk gesture, and it invents nothing.
614
+ is the honest answer to "when" and stays one click from being filled.
648
615
 
649
616
  **A CHECKLIST REPORTS; IT DOES NOT COLLECT, AND IT DOES NOT PREVIEW.** A field some section owns —
650
617
  an assignee, an address, a tax ID — is NAMED as a gap by a **`ChecklistNote`** whose `action` jumps
651
- to that section. Never an editor in the row: two edit surfaces for one fact, and every row becomes
652
- two things at once (a status and a form) with nothing to say which one the reader is in. Never a
618
+ to that section. Never an editor in the row: two edit surfaces for one fact. Never a
653
619
  rendered value either — an avatar or a badge is a COPY that has to be kept in step with the
654
- field's real home, and readers start treating the list as the place the value lives. Two
620
+ field's real home. Two
655
621
  exceptions, both narrow: **`ChecklistItem.trailing`** for the row's OWN stamp (a tick writes today,
656
622
  so a run filled in after the fact needs the correction in reach), and **`ChecklistField`** for a
657
623
  value with NO other home — a portal login created at that step, unreachable otherwise.
@@ -662,9 +628,7 @@ is a `ChecklistNote`, because a sentence on the title's line wraps into the titl
662
628
  scanning. Neither takes a LABEL: a field is a named value the reader sets, and labelling prose
663
629
  makes it read as a field nobody can edit.
664
630
 
665
- **Anything that needs SCANNABLE COLUMNS is a `Table`.** The compound once carried a declared VALUE
666
- column so cells lined up down the list; it aligned, and it read worse — each name ended a quarter
667
- of the surface from its control, sized for the widest value on the page. Comparing the same four
631
+ **Anything that needs SCANNABLE COLUMNS is a `Table`.** Comparing the same four
668
632
  values across twenty rows is a table's job.
669
633
 
670
634
  **State flows from the LEAVES.** Where a row's position is computable from what it owns, DERIVE it
@@ -683,10 +647,9 @@ same empty list, so a badge asserting the first states something no one establis
683
647
  at both ends, and separate "is it under way" (which drives the ring) from "has it anything to
684
648
  say" (which drives the caption) — fusing them forces every pending state to invent a label.
685
649
 
686
- `CheckCircle` has THREE positions (`state="none" | "partial" | "done"`), and `partial` is not
687
- optional dressing: a step whose vocabulary reads "No portal account → Account created → Filed
688
- online" is neither finished nor untouched in the middle, and a binary ring calls it untouched in
689
- the gutter column, which is the fastest scan on the surface. Its CLICK stays binary (finish /
650
+ `CheckCircle` has THREE positions (`state="none" | "partial" | "done"`): a step whose vocabulary
651
+ reads "No portal account → Account created → Filed
652
+ online" is neither finished nor untouched in the middle, and a binary ring calls it untouched, in the gutter column. Its CLICK stays binary (finish /
690
653
  reopen) so the ring means one thing everywhere; `partial` is reached through whatever NAMES it —
691
654
  an `InlineSelect` status cell in that step's own words, or children ticking off — never by cycling
692
655
  the ring. Keep the ring MONOCHROME and let colour live in the status cell, or the row double-codes
@@ -707,7 +670,7 @@ the record left this register.
707
670
  Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
708
671
  whose popover holds the same `Checklist` — every `ChecklistItem`'s ring and title, and nothing a
709
672
  section already owns (a reassign belongs to the record, not to a peek over fifty rows) — no
710
- expandable rows (tags/files depth is the task board's lesson, not the peek's); the popover body is `PopoverContent`'s own
673
+ expandable rows; the popover body is `PopoverContent`'s own
711
674
  ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
712
675
  `OptionList`). The DRAWER carries a real Tasks SECTION in the Record template's shape (heading +
713
676
  the compact meter + the same checklist) — one shared task state per record feeds the column, the
@@ -783,7 +746,7 @@ create row. Borderless for a grid cell? Pass a `style`, never a `variant`.
783
746
  editors, so the tag field wears the same frame and hover as the row it sits in; reach for `Select`
784
747
  inside a form or dialog. The ✕ detaches its tag whether the list is open or shut and never opens
785
748
  the list. Two densities, one seam: a record field earns the removable `Chip`, a narrow register
786
- column keeps the plain badge, where the ✕ costs more width than the reopen it saves. `Combobox` is the
749
+ column keeps the plain badge. `Combobox` is the
787
750
  SINGLE-value sibling — for a search that emits one pick at a time and renders the selection
788
751
  elsewhere, use `reflectSelection={false}` (see the [templates](./templates.md)).
789
752
 
@@ -815,14 +778,13 @@ sitting on the inline-control grid via `InlineSlot`.
815
778
 
816
779
  "Add another" is not an act a single-file field can perform, and offering it produces a field
817
780
  holding two of something the schema says there is one of. The component gets the affordance
818
- right; it cannot enforce the persistence, because it does not own the data so the difference
819
- lives at the call site, `onAdd={([f]) => setFile(f)}` against
781
+ right; it cannot enforce the persistence so the difference lives at the call site, `onAdd={([f]) => setFile(f)}` against
820
782
  `onAdd={(fs) => setFiles([...files, ...fs])}`, where it is decided.
821
783
 
822
784
  **Nothing to attach TO yet is a real state.** Pass `blockedReason` and the CTA is replaced by a
823
785
  sentence naming the act that unblocks it ("attach the invoice number first"). Never a disabled
824
786
  button: disabled says you may not and leaves the reader to work out why and what would change
825
- it — same pixels, one of them useful.
787
+ it.
826
788
 
827
789
  **A LIST, and not switchable.** Documents are identified by NAME; a PDF/Word/Excel thumbnail is
828
790
  a grey page identical to every other grey page, so a grid of them is a wall of one tile under a
@@ -866,10 +828,10 @@ intake paths, all wired to ONE handler:
866
828
  **What `accept` is matched against, and the one thing a paste cannot do.** A pattern is an
867
829
  extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME. A file is matched on
868
830
  `resolveMimeType(file.type, file.name)` — its own declared type when that type says something,
869
- and what its EXTENSION says when it doesn't. That fallback is not a nicety: a file copied in
831
+ and what its EXTENSION says when it doesn't. A file copied in
870
832
  Finder/Explorer and pasted arrives with real bytes and a real filename but an uninformative
871
- type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
872
- of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
833
+ type — `""`, or just as often `application/octet-stream`. Judging it on that reads silence as
834
+ "not a PDF", the file is discarded, and since an
873
835
  unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
874
836
  the resolved type also means the surface agrees with what the server will store. The kit's
875
837
  extension table covers documents, images and A/V; the server reads a far larger one, so an
@@ -885,8 +847,8 @@ the paste goes to the TOP-MOST one whose `region` contains focus (`document.acti
885
847
  so two peer file sections each win while the user is working in them, never "last-mounted
886
848
  silently grabs everything." With none focused it falls back to the top of the stack (the
887
849
  last-mounted surface — the right default for a dialog stacked over a screen). A `usePasteFiles`
888
- with no `region` is a pure stack participant, so a single existing consumer is byte-for-byte
889
- unchanged. `FileDropTarget paste` passes its own region, so you get focus routing for free.
850
+ with no `region` is a pure stack participant. `FileDropTarget paste` passes its own region, so
851
+ you get focus routing for free.
890
852
 
891
853
  Both drop and paste are web behaviors with native no-op siblings — wrap/call them
892
854
  unconditionally on a shared screen. The two templates are the worked examples of the
@@ -925,21 +887,13 @@ own order, under their own names.
925
887
  ```
926
888
 
927
889
  **A HOST verb is a plain `Button`** that reads `useFilesEditorSelection()` →
928
- `{selected, selectedIds, files, selectMode, clear, exit}`. This is the whole reason the bar is
929
- composed: an act the kit has never heard of read these with AI, send them to the broker, ZIP
930
- them has a home, instead of forcing the screen to hand-roll the grid and lose the gallery and
931
- the upload queue with it.
932
-
933
- ```tsx
934
- function ReadWithAi() {
935
- const { selected, exit } = useFilesEditorSelection();
936
- return <Button title="Read with AI" disabled={selected.length === 0}
937
- onPress={() => { run(selected); exit(); }} />;
938
- }
939
- ```
890
+ `{selected, selectedIds, files, selectMode, clear, exit}` `disabled` while `selected` is empty,
891
+ and it calls `exit()` when the act is done (`clear` drops the selection but stays in select mode).
892
+ This is the whole reason the bar is composed: an act the kit has never heard of read these with
893
+ AI, send them to the broker, ZIP them — has a home.
940
894
 
941
895
  **Each piece renders nothing without the handler it needs**, so composition expresses the
942
- variants that used to be flags: withhold `onAdd` and there is no Upload; withhold `onRemove` and
896
+ variants: withhold `onAdd` and there is no Upload; withhold `onRemove` and
943
897
  there is no Remove and no per-tile ✕ — that IS read-only, with no second mode to keep in sync.
944
898
  **No children means no bar**: a grid that previews and nothing else.
945
899