@lotics/ui 45.10.0 → 46.0.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.
- package/AGENTS.md +70 -137
- package/MIGRATION.md +27 -0
- package/docs/ai_patterns.md +166 -160
- package/docs/catalog.md +223 -287
- package/docs/composition.md +394 -518
- package/docs/data_entry.md +109 -155
- package/docs/reviewing.md +39 -55
- package/docs/templates.md +459 -423
- package/docs/testing.md +3 -7
- package/package.json +1 -1
- package/src/agent_progress.tsx +5 -4
- package/src/agent_run.tsx +218 -44
- package/src/agent_run_pane.tsx +5 -0
- package/src/agent_transform.ts +34 -0
- package/src/locale.tsx +3 -7
- package/src/pressable_row.tsx +7 -5
- package/src/table.tsx +15 -14
package/docs/data_entry.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
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.
|
|
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
|
|
95
|
-
|
|
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.
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
133
|
-
box
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
|
168
|
-
host that draws it, and here there is no host
|
|
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`)
|
|
207
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
323
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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)
|
|
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
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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.
|
|
496
|
-
contradict it
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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)
|
|
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
|
|
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
|
|
526
|
-
|
|
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
|
|
559
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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`.**
|
|
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"`)
|
|
687
|
-
|
|
688
|
-
online" is neither finished nor untouched in the middle, and a binary ring calls it untouched
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
872
|
-
|
|
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
|
|
889
|
-
|
|
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}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
the
|
|
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
|
|
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
|
|