@jsenv/navi 0.29.417 → 0.29.419

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +347 -83
  3. package/dist/dev/jsenv_navi.js.map +12 -12
  4. package/dist/jsenv_navi.js +345 -81
  5. package/dist/jsenv_navi.js.map +12 -12
  6. package/docs/AI_INSTRUCTIONS.md +177 -258
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +291 -454
  16. package/docs/drag_to_travel.md +152 -748
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +170 -326
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -473
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -317
  37. package/docs/state_binding.md +40 -84
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -32,8 +32,6 @@ was missed.
32
32
 
33
33
  ## The callback that only remembers
34
34
 
35
- The same mistake, in four places:
36
-
37
35
  ```jsx
38
36
  // ✗ the app is told where the slides went, and writes it back down
39
37
  <SlideContainer
@@ -45,18 +43,6 @@ The same mistake, in four places:
45
43
  // ✓
46
44
  <SlideContainer signal={stepSignal} />
47
45
 
48
- // ✗ the control is told what it now holds, and the app writes it back down
49
- <Picker value={side} uiAction={(value) => (sideSignal.value = value)} />
50
- // ✓
51
- <Picker signal={sideSignal} />
52
-
53
- // ✗ a button proposing a value to a control, straight into the signal
54
- <Button onClick={() => (durationSignal.value = ninetyMinutes)}>1h30</Button>
55
- // ✓
56
- <Button command="--navi-update" commandFor="duration" value={ninetyMinutes}>
57
- 1h30
58
- </Button>
59
-
60
46
  // ✗ the popup is told it opened, and the app writes it back down
61
47
  <Dialog
62
48
  open={isOpen}
@@ -69,7 +55,8 @@ The same mistake, in four places:
69
55
 
70
56
  In each pair the second line is not a tidier spelling of the first: it is the
71
57
  binding the component already implements, and the first is a partial
72
- re-implementation of it.
58
+ re-implementation of it. The same pair for a control's value, and for a button
59
+ proposing one, is in [control_value.md](./control_value.md).
73
60
 
74
61
  ## Why it is not merely shorter
75
62
 
@@ -77,22 +64,20 @@ A binding is written by **everything that moves the state**, not only by the
77
64
  gesture the caller thought of. A `SlideContainer`'s signal is written by a drag,
78
65
  by an arrow key, by a `--navi-right` command sent from a chevron outside the
79
66
  box, by the address when the signal is a route's own, by a step refusing to be
80
- left. A control's
81
- signal is written by the user typing, by a group placing its children, by a
82
- picker committing on close, by a `--navi-update` from a shortcut button. A
83
- hand-written callback catches the cases it was written for and silently misses
84
- the rest — and the drift only shows on the screen where a second gesture was
85
- added.
67
+ left. A control's signal is written by the user typing, by a group placing its
68
+ children, by a picker filling its popup or putting back what it held on Escape,
69
+ by a `--navi-update` from a shortcut button. A hand-written callback catches the
70
+ cases it was written for and silently misses the rest — and the drift only shows
71
+ on the screen where a second gesture was added.
86
72
 
87
73
  It reads in the other direction too, which no callback gives at all: writing the
88
74
  signal MOVES the component. The wheels roll, the slides travel, the popup opens.
89
75
  That is what replaces the `key` or the `value` prop pushed down to force a
90
76
  component into a state.
91
77
 
92
- And it goes through navi's interaction gate. A `command` on a read-only,
93
- disabled or busy control is refused and says why; an `onClick` doing the same
94
- write is plain DOM, fires on a greyed-out button and rewrites a value nobody was
95
- allowed to change.
78
+ And a `command` goes through navi's interaction gate, which an `onClick` writing
79
+ the same state does not (see
80
+ [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
96
81
 
97
82
  ## What is left for the callback
98
83
 
@@ -113,13 +98,10 @@ Binding the state does not remove the callback — it removes one job from it:
113
98
  ## The callback also fires for the state's own changes
114
99
 
115
100
  A bound container walks to whatever its signal holds, and tells the callback
116
- about that walk — `cause: "state"`, `event: null`. It is the only piece that
117
- knows the change was not a press, and saying so is its job.
118
-
119
- The trap is on the other side. A callback that writes somewhere whose answer
120
- flows back INTO the signal — a server whose response is upserted into the store,
121
- a `useSignalSync` on the row that re-renders from it — makes every answer a
122
- change, and every change a write:
101
+ about that walk — `cause: "state"`, `event: null`. The trap is a callback that
102
+ writes somewhere whose answer flows back INTO the signal — a server whose
103
+ response is upserted into the store, a `useSignalSync` on the row that
104
+ re-renders from it:
123
105
 
124
106
  ```jsx
125
107
  // ✗ the answer of the write feeds the signal the write came from
@@ -130,49 +112,30 @@ const styleSignal = useSignalSync(user.share_image_style);
130
112
  />;
131
113
  ```
132
114
 
133
- With one write at a time nothing shows: the answer carries the value the signal
134
- already holds, so the model does not change. It takes two writes in flight — two
135
- notches 200ms apart, a 300ms round trip:
136
-
137
- ```
138
- t=0 notch → B signal=B PUT B ──────────────┐
139
- t=200 notch → A signal=A PUT A ────────────┐ │
140
- t=300 B answers, store=B, signal=B, the box │ │
141
- walks to B, onCurrentChange(B, "state"), │ │
142
- save(B) → PUT B ───────────────────────┐ │ │
143
- t=500 A answers … save(A) → PUT A │ │ │
144
- t=600 B answers … save(B) → PUT B │ │ │
145
- …
146
- ```
147
-
148
- Nothing was reordered — it is enough that an answer lands after a newer write
149
- was sent. Every answer is then the answer to a request the user has already
150
- moved past, the container obeys the model, the callback saves what it was just
151
- told, and that write's answer starts the next turn.
115
+ One write at a time shows nothing: the answer carries the value the signal
116
+ already holds. Two in flight — two notches in quick succession, nothing
117
+ reordered, only an answer landing after a newer write was sent — and every
118
+ answer is to a request the user has already moved past: the container walks to
119
+ it, the callback saves it, and that write's answer starts the next turn. The
120
+ loop never closes.
152
121
 
153
122
  Decide what the signal IS:
154
123
 
155
124
  - **The signal is the choice, the server only confirms it.** Seed it once with
156
- `useSignal(model)` and never sync it. A late answer changes the store, not the
157
- picture. The cost: a write that fails is not undone on screen unless the
158
- callback undoes it. Right for a setting saved on the gesture, with no "Save"
159
- button.
125
+ `useSignal(model)` and never sync it: a late answer changes the store, not the
126
+ picture, and a write that fails is not undone on screen unless the callback
127
+ undoes it. Right for a setting saved on the gesture, with no "Save" button.
128
+ That is a `SlideContainer`'s shape; a control says it with
129
+ `defaultValue={model}` and an `action` (see
130
+ [control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
160
131
  - **The model is the truth, the control only proposes.** Keep `useSignalSync`
161
- and ignore `cause: "state"` in the callback — a change the state asked for is
162
- not a choice to save. The cost: a late answer still pulls the picture back for
163
- one round trip. Right when another client or a background refresh may change
164
- the row while the control is on screen.
132
+ and ignore `cause: "state"` — a change the state asked for is not a choice to
133
+ save. A late answer still pulls the picture back for one round trip. Right
134
+ when another client or a background refresh may change the row while it is on
135
+ screen.
165
136
  - **Never two writes in flight for one field.** Debounce the write, or cancel
166
- the one before it (`createRequestCanceller`), so no answer can be older than
167
- the latest intent. Right when the write is the expensive part.
168
-
169
- Measured on the same screen, the same two notches, a 300ms round trip:
170
-
171
- | what the signal is | writes sent | slide changes |
172
- | ----------------------------------------- | --------------- | ------------- |
173
- | `useSignalSync` + save every change | 39, still going | 39 |
174
- | `useSignal`, seeded once | 2 | 2 |
175
- | `useSignalSync` + ignore `cause: "state"` | 2 | 4 |
137
+ the one before it (`createRequestCanceller`), so no answer is older than the
138
+ latest intent. Right when the write is the expensive part.
176
139
 
177
140
  ## What binds itself
178
141
 
@@ -189,13 +152,10 @@ Measured on the same screen, the same two notches, a 300ms round trip:
189
152
 
190
153
  "Who writes the score" has three answers — a player of the game, the organizer
191
154
  alone, nobody — and only two of them are rows: "nobody" is a switch under the
192
- list. So the state holds a value the list cannot show, and binding the list to
193
- that state leaves it showing nothing whenever the switch is on.
194
-
195
- The reflex is to fall back on `value` + `uiAction` and remember the row by hand.
196
- The finding is elsewhere: **the list is not asking that question**. It asks
197
- "which of these two", the switch asks "anybody at all", and the answer the
198
- screen sends is made of both:
155
+ list. Bound to the state holding all three, the list shows nothing whenever the
156
+ switch is on. The fix is not `value` + `uiAction`: **the list is not asking that
157
+ question**. It asks "which of these two", the switch asks "anybody at all", and
158
+ the answer the screen sends is made of both:
199
159
 
200
160
  ```jsx
201
161
  const scorerSignal = signal("player"); // what the list asks: one of ITS rows
@@ -210,13 +170,10 @@ const whoWritesSignal = computed(() =>
210
170
  ```
211
171
 
212
172
  Each control binds the state it is about, and the value that spans them is
213
- computed from those — never held a second time. It also gives back for free what
214
- the hand-written version had to arrange: the row chosen before the switch went
215
- on is still chosen when it goes off, because nothing ever cleared it.
216
-
217
- The rule underneath: a control shows values, so the state it binds is the one
218
- whose values it can show. A state holding more than that is more than one
219
- question.
173
+ computed from those — never held a second time (the row chosen before the switch
174
+ went on is still chosen when it goes off: nothing cleared it). A control binds
175
+ the state whose values it can show; a state holding more than that is more than
176
+ one question.
220
177
 
221
178
  ## When there is no binding
222
179
 
@@ -235,7 +192,6 @@ the next screen copies, and by then it is the app's pattern.
235
192
 
236
193
  - [control_value.md](./control_value.md) — who holds a control's value, and what
237
194
  a bound signal holds control by control
238
- - [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`
239
195
  - [popup_open.md](./popup_open.md) — who owns the fact that a popup is open
240
196
  - [interactions.md](./interactions.md) — a gesture is named, not read by hand:
241
197
  the same rule, one layer down
package/docs/testid.md CHANGED
@@ -71,12 +71,10 @@ on. A testid on the wrapper would answer a click with a hit on the padding.
71
71
  </span>
72
72
  ```
73
73
 
74
- This routing is what `CONTROL_ATTRIBUTE_SET` in
75
- `src/control/control_context.js` lists: the props navi hands to the host rather
76
- than to the box around it (`id`, `name`, `type`, `value`, `tabIndex`,
77
- `data-testid`, …). Any prop navi does not know lands on the root box instead —
78
- so a `data-test-id` or a `data-qa` of your own would name the wrapper, which is
79
- the second reason to keep the standard spelling.
74
+ Navi hands the host the props it knows belong there (`id`, `name`, `type`,
75
+ `value`, `tabIndex`, `data-testid`, …); any prop it does not know lands on the
76
+ root box instead — so a `data-test-id` or a `data-qa` of your own would name
77
+ the wrapper, which is the second reason to keep the standard spelling.
80
78
 
81
79
  `aria-label` and `aria-labelledby` take the same road, and for the reason the
82
80
  section above gives: the name has to be where the role is. The root box has no
@@ -124,16 +122,16 @@ error message, a section, a row — put the testid on the surrounding component
124
122
  (`<Field data-testid="email-field">`, `<Box data-testid="cart-row">`); anything
125
123
  built on `Box` forwards it to its own element.
126
124
 
127
- A selectable `List.Item` is a control too, and the rule holds there with a
128
- consequence worth knowing: its host is the real checkbox or radio the row
129
- selects with, an input navi draws invisible (`navi-visually-hidden`) and lays
130
- under the row's content. A `data-testid` on the item names that input — what
131
- `toBeChecked()` reads, not what the eye sees. A test that presses the row, or
132
- waits for it to show, targets what the row draws: a testid on its content.
125
+ A selectable `List.Item` is a control too, and the rule holds there: its host is
126
+ the real checkbox or radio the row selects with, an input navi draws invisible
127
+ and lays under the row's content. A `data-testid` on the item names that input —
128
+ what `toBeChecked()` reads, not what the eye sees. A test that presses the row,
129
+ or waits for it to show, targets what the row draws: a testid on its content.
133
130
 
134
- A picker's popup is the one element the application does not render itself:
135
- `<Picker>` builds it, so a `data-testid` on the picker names the trigger and
136
- nothing names the popup. `popupTestId` does:
131
+ What navi builds itself is named through props. A `data-testid` on a `<Picker>`
132
+ names the trigger; `popupTestId` names the popup it builds (the callout, under
133
+ `mode="callout"`), and `confirmTestId` / `cancelTestId` the yes and no buttons
134
+ of a `type="confirm"` picker's default body:
137
135
 
138
136
  ```jsx
139
137
  <Picker data-testid="tie-break" popupTestId="tie-break-sheet">
@@ -141,13 +139,11 @@ nothing names the popup. `popupTestId` does:
141
139
  </Picker>
142
140
  ```
143
141
 
144
- Most tests do not need it — a testid on what the popup holds (`<Box
142
+ Most tests do not need `popupTestId`: a testid on what the popup holds (`<Box
145
143
  data-testid="place-pick">` among the children) names the screen, not the frame,
146
144
  and is the better name for a test that reads or clicks the content. Reach for
147
- `popupTestId` when the frame IS what the test looks at: a screenshot of the
148
- popup's surface, its position, its size, its backdrop. It names the popup in
149
- every mode, including `mode="callout"`, where the frame is a callout rather
150
- than a sheet.
145
+ it when the frame IS what the test looks at — a screenshot of the popup's
146
+ surface, its position, its size, its backdrop.
151
147
 
152
148
  ## A callout is a role first
153
149
 
@@ -168,9 +164,8 @@ for it is what a live region is for, and the role is what makes a screen reader
168
164
  read it out. A test targeting it asserts that.
169
165
 
170
166
  When the role is not enough — two callouts up at once, a screenshot of one
171
- particular surface — `openCallout` takes a `testId`, and a picker's
172
- `popupTestId` covers the one it opens. Waiting on `.navi_callout` is the thing
173
- to replace: it is a navi class like any other.
167
+ particular surface — `openCallout` takes a `testId`. Waiting on `.navi_callout`
168
+ is the thing to replace: it is a navi class like any other.
174
169
 
175
170
  `<ControlSwap.Side>` answers the same situation the other way: the cap at the
176
171
  end of the row is drawn by navi, and every prop the side does not use for
@@ -38,11 +38,11 @@ same names — `size="l"` is a font size, `padding="l"` is a gap.
38
38
 
39
39
  Four of the five are formulas on `currentColor`, so they follow whatever ink
40
40
  their container writes in — a dark card sets `color` and nothing else. Never
41
- write the `--navi-color-*` variable behind a keyword by hand: the keyword is the
42
- name, and an invented one (`var(--navi-text-color-discrete)`) resolves to
43
- nothing and silently inherits. What the ratios are, how a theme changes them and
44
- why a surface re-declares them:
45
- [css_architecture.md](./css_architecture.md#ink-ratio-paper-the-color-keywords).
41
+ read the `--navi-color-*` variable behind a keyword with `var()`: the keyword is
42
+ the name, and an invented one (`var(--navi-text-color-discrete)`) resolves to
43
+ nothing and silently inherits. Declare one only to pin a container's paper —
44
+ what the ratios are, how a theme changes them and why a surface re-declares
45
+ them: [css_architecture.md](./css_architecture.md#ink-ratio-paper-the-color-keywords).
46
46
 
47
47
  Live examples: `src/text/demos/*_demo.html` — one page per concern
48
48
  (`text_overflow_demo.html`, `text_spacing_demo.html`, `text_loading_demo.html`,
@@ -61,59 +61,19 @@ implements them with two unrelated mechanisms:
61
61
  ```
62
62
 
63
63
  Do **not** write `lineClamp={1}`. `lineClamp` and `overflowEllipsis` are raw
64
- `Box` style props: they map to `-webkit-line-clamp` and to
65
- `overflow/text-overflow` one-to-one, and are there for the case where an element
66
- is not a `Text` (or deliberately opts out of it) and still needs that CSS. They
67
- know nothing about each other, so `lineClamp={1}` gives a single-line webkit box
68
- without the single-line handling `maxLines={1}` brings — `maxLines` switches the
69
- element to a block, sets `min-width: 0` on itself, and states the `white-space`
70
- its line count needs: one line does not wrap, n lines may (a `<p>` keeps its
71
- line breaks in both cases). That last part is what makes it hold anywhere:
72
- `white-space` is inherited, and a raw clamp put under a single-line ancestor — a
73
- `Picker`'s value, a `Time` — inherits `nowrap`, gets one line to cut, and lets
74
- the text run past its box instead. On a `Text`, `maxLines` is always the right
75
- answer, and an explicit `noWrap`/`pre`/`preLine` beside it still wins.
76
-
77
- ### Who takes `maxLines`
78
-
79
- The prop is the same everywhere: a number of lines, cut with an ellipsis.
80
-
81
- - **`Text`** and everything built on it — `Title`, `Paragraph`, `Caption`,
82
- `Link`, `Time`, `Quantity`… — the prop flows through. `Badge` is `maxLines={1}`
83
- by construction.
84
- - **`Picker`** — its value, `maxLines={1}` by default; `variant="text"` and
85
- `variant="bare"` draw the caller's own thing and are not clamped, a `Text` in
86
- their `ui` cuts itself with its own `maxLines`.
87
- - **`Spin`** (`DaySpin`, `NumberSpin`…) — its value; left out, a long value
88
- wraps and the box grows.
89
- - **`Binder`** — its tab labels, `maxLines={1}` by default, `false` lets them
90
- wrap; overridable per item.
91
- - **`BadgeList`** — its rows, measured (a wrapped flex row is not a line box):
92
- the badges that fit and a "+N" badge for the rest. Inside a `Picker` it
93
- inherits the picker's count, see [badge_list.md](./badge_list.md).
94
-
95
- On a plain `Box`, `maxLines` is the raw CSS mapping described above: it clamps
96
- whatever the box holds, and states nothing else.
97
-
98
- ### Truncation only happens if something says "you may shrink"
99
-
100
- A flex or grid item refuses to become narrower than its content unless it is
101
- told it may. `maxLines` sets `min-width: 0` on the `Text` itself, so the leaf is
102
- covered — but **every `Box` between it and the element that actually has a
103
- width must say it too**, or the whole chain grows instead of truncating:
104
-
105
- ```jsx
106
- <Box flex width="300" spacing="s">
107
- {/* without minWidth the row just grows */}
108
- <Box flex expandX minWidth="0">
109
- <Text maxLines={1}>…</Text>
110
- </Box>
111
- </Box>
112
- ```
113
-
114
- Symmetrically, whatever must keep its full size says so with `shrink={false}`.
115
- A row where the text gives way and nothing else does is the normal shape — see
116
- the recipes below.
64
+ `Box` style props, mapped one-to-one to CSS for an element that is not a `Text`.
65
+ `maxLines` also states the `white-space` its line count needs — one line does
66
+ not wrap, n lines may — and that is what makes it hold anywhere: `white-space`
67
+ is inherited, and a raw clamp put under a single-line ancestor — a `Picker`'s
68
+ value, a `Time` — inherits `nowrap`, gets one line to cut, and lets the text run
69
+ past its box instead. An explicit `noWrap`/`pre`/`preLine` beside `maxLines`
70
+ still wins.
71
+
72
+ The prop is the same wherever it is taken — a `Picker`'s value (1 by default),
73
+ a `Spin`'s value, `Binder`'s tab labels, a `BadgeList`'s rows (see
74
+ [BadgeList](#badgelist)); `Badge` is `maxLines={1}` by construction. On a plain
75
+ `Box`, `maxLines` is the raw CSS mapping: it clamps whatever the box holds, and
76
+ states nothing else.
117
77
 
118
78
  ## A row: icon, text, icon
119
79
 
@@ -145,22 +105,34 @@ The three parts of it, and each is load-bearing: `shrink={false}` on the icons
145
105
  (they are the fixed part), `expandX` on the text (it is the part that gives),
146
106
  `maxLines={1}` (what giving way means for text). Drop any one and the row fails
147
107
  in a different way — icons squashed, ellipsis never appearing, or the row
148
- overflowing its container.
108
+ overflowing its container. Same shape for a count, a badge or a status kept to
109
+ the right of the ellipsis: a sibling with `shrink={false}`.
110
+
111
+ A flex or grid item refuses to become narrower than its content unless it is
112
+ told it may. `maxLines` sets `min-width: 0` on the `Text` itself, but **every
113
+ `Box` between it and the element that actually has a width must say it too**,
114
+ or the whole chain grows instead of truncating:
149
115
 
150
- Same shape for a count, a badge or a status kept to the right of the ellipsis:
151
- it is a sibling with `shrink={false}`, not a child of the truncating text.
116
+ ```jsx
117
+ <Box flex width="300" spacing="s">
118
+ {/* without minWidth the row just grows */}
119
+ <Box flex expandX minWidth="0">
120
+ <Text maxLines={1}>…</Text>
121
+ </Box>
122
+ </Box>
123
+ ```
152
124
 
153
125
  ### The text wraps — the end icon must not be left alone
154
126
 
155
127
  No truncation here: the text is allowed to take several lines. The trap is the
156
128
  last child. An icon (or a unit, or an arrow) is an atomic inline, so the browser
157
- is free to break the line right before it — and no character can prevent that; a
158
- word joiner does not suppress a break before an atomic inline. On the wrong
129
+ is free to break the line right before it — and no character can prevent that;
130
+ a word joiner does not suppress a break before an atomic inline. On the wrong
159
131
  container width, the icon ends up alone on a line under the label.
160
132
 
161
133
  `attachLastChild` fixes it: the last child and the **last word** before it are
162
- put in one `white-space: nowrap` box, the classic widow fix applied to the one
163
- place where breaking is always wrong.
134
+ put in one `white-space: nowrap` box — only the last word, since wrapping the
135
+ whole preceding text would stop a long label from wrapping at all.
164
136
 
165
137
  ```jsx
166
138
  <Text attachLastChild>
@@ -171,114 +143,112 @@ place where breaking is always wrong.
171
143
  </Text>
172
144
  ```
173
145
 
174
- Only the last word travels with the child — wrapping the whole preceding text
175
- would stop a long label from wrapping at all.
176
-
177
146
  `Link` does this on its own whenever it renders an end icon (`endIcon`,
178
- `anchorIcon`, the external-target one), which is why a document's table of
179
- contents never drops an arrow on a line of its own. Write `attachLastChild`
180
- yourself when you build such a pair outside `Link`.
147
+ `anchorIcon`, the external-target one). Write `attachLastChild` yourself when
148
+ you build such a pair outside `Link`.
181
149
 
182
150
  ## Spacing between children
183
151
 
184
152
  `Text` injects a separator between its children whenever at least one side is an
185
- element — so an icon and a label are spaced without a manual `{" "}`, and two
186
- plain strings are left alone. `spacing` changes it: a size token (`"s"`, `"m"`,
187
- …), a CSS length, a number (px), a string used verbatim, or `"pre"` / `0` to
188
- inject nothing.
189
-
190
- Two things opt out of that flow:
191
-
192
- - `markAsOutsideTextFlow(Component)` — for something rendered inside a `Text`
193
- that takes no room in the line (an absolutely positioned indicator, an
194
- overlay). Without it, a separator is injected next to something invisible and
195
- leaves a stray gap.
196
- - `preventSpaceUnderlines` — inside an `<a>`, browsers draw the underline under
197
- the space characters too. This replaces them with padding-based spaces, so the
198
- underline stops at the text. `Link` sets it.
153
+ element — an icon and a label are spaced without a manual `{" "}` — and
154
+ `spacing` changes it. Two things opt out of that flow:
155
+ `markAsOutsideTextFlow(Component)`, for something rendered inside a `Text` that
156
+ takes no room in the line (an absolutely positioned indicator, an overlay),
157
+ where a separator would leave a stray gap next to something invisible; and
158
+ `preventSpaceUnderlines`, which `Link` sets, so an underline stops at the text
159
+ instead of running under the spaces.
199
160
 
200
161
  ## Emoji
201
162
 
202
163
  **An emoji is not a character the layout can absorb.** The system emoji fonts
203
- (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji) have a taller
204
- ascent/descent than any text font, and under `line-height: normal` a line box
205
- takes the height of the tallest font it holds. So the moment an emoji sits in a
206
- line, that line is taller than the ones around it: a row shifts down next to its
207
- neighbours, a paragraph's lines are unevenly spaced, a truncated label no longer
208
- lines up with its siblings. Tighten the line to get the rows even again and the
209
- glyph is clipped instead — its box is taller than a letter's, and what sticks
210
- out is simply cut.
164
+ have a taller ascent/descent than any text font, and under `line-height: normal`
165
+ a line box takes the height of the tallest font it holds. So the moment an emoji
166
+ sits in a line, that line is taller than the ones around it: a row shifts down
167
+ next to its neighbours, a paragraph's lines are unevenly spaced. Tighten the
168
+ line to get the rows even again and the glyph is clipped instead.
211
169
 
212
170
  **One number answers both: `--navi-line-height`, 1.25.** Tall enough to contain
213
171
  an emoji's own box, so nothing is clipped; tight enough that a line carrying one
214
172
  is exactly as tall as a line of plain text, so nothing moves. 1 cuts the top off
215
173
  the glyph, 1.5 spaces the rows out more than reading them asks for. **An app
216
174
  that displays what people typed cannot go below 1.25** — that is the floor this
217
- token encodes, and the reason it is a token rather than a number repeated in
218
- every component.
175
+ token encodes.
219
176
 
220
177
  **Everything is written on that line, controls included.** The document sets it
221
- on `:root`, and the components that would otherwise come with a line of their
222
- own from the browser — `Button`, `Input`, `Textarea`, `Select` — are handed it
223
- by name: a form control inherits nothing from the page on its own, and what it
224
- starts from is `normal`, the one value the emoji breaks. `Picker` and the items
225
- of a selectable `List` take it too, though they are drawn by hand: they stand
226
- in a row with those, sized in `lh` like them, and have to be the same number
227
- of pixels tall. That is what makes a
228
- value keep its line, and its emoji its size, as it passes from the field it was
229
- typed in to whatever displays it afterwards — an emoji typed in an input is not
230
- clipped, and the same emoji displayed in a row, a bubble or a button does not
231
- push anything around. `text_emoji_demo.html` shows it by swapping the field and
232
- the rendering in place.
178
+ on `:root`, and the controls that would otherwise start from the browser's
179
+ `normal` — the one value the emoji breaks — are handed it by name: `Button`,
180
+ `Input`, `Textarea`, `Select`, and `Picker` and selectable `List` items, which
181
+ stand in a row with them. A form control inherits nothing from the page on its
182
+ own. So a value keeps its line, and its emoji its size, from the field it was
183
+ typed in to whatever displays it afterwards (`text_emoji_demo.html`).
233
184
 
234
185
  A control takes it snapped to the pixel (`--navi-control-line-height`,
235
- `round(1.25em, 1px)`). The browser lays a line out at its exact height but
236
- paints the glyph on a pixel row, and at the default control size (13.333px)
237
- the line is 16.666px: the two-thirds that do not fit go entirely under the
238
- glyph, which then sits a pixel above the middle of its field — the placeholder
239
- looks too high. A whole number of pixels has no remainder to put anywhere, and
240
- a `Textarea` sized in `lh` lands on the same grid as the placeholder it
241
- measures. The page's text keeps the plain number: a length would stop
242
- following the font size the moment it is inherited.
186
+ `round(calc(var(--navi-line-height) * 1em), 1px)`): a fractional line puts its
187
+ remainder under the glyph, which then sits a pixel above the middle of its
188
+ field. The page's text keeps the plain number, which follows the font size when
189
+ inherited.
243
190
 
244
191
  Change it on `:root` for a whole app. Do not unset it on a component, do not let
245
192
  one fall back to `normal`, and do not raise a local `lineHeight` because of an
246
193
  emoji: a `lineHeight` is a typographic choice for the text itself — a paragraph
247
194
  that wants air — never a workaround for a glyph that grew too tall.
248
195
 
249
- **Where an emoji belongs is a separate question, and it is not a layout one.**
250
- A field can still refuse emoji outright (`noEmoji`) where the value is an
251
- identifier rather than free text. A name is the case worth stating: people are
252
- called what they are called, and a whitelist on a name field ends up refusing
253
- somebody's real name — a name field keeps to the rules that are about what can
254
- be drawn at all (`data-displayable`: marks stacked into zalgo, a value that
255
- shows nothing, blank lines in series).
196
+ Where an emoji belongs is a separate question, and not a layout one: a field
197
+ refuses one with `noEmoji`, and a name field — people are called what they are
198
+ called — keeps to what can be drawn at all (`displayable`) rather than a
199
+ whitelist. See [field_validation.md](./field_validation.md#constraints).
256
200
 
257
201
  ## Text that must not move when its style changes
258
202
 
259
203
  A label that becomes bold when its row is selected reflows everything around it.
260
- Two answers, and the one to pick depends on whether the text wraps:
261
-
262
- - `holdSpaceForStyle={{ fontWeight: "bold" }}` — an invisible copy rendered in
263
- the target style reserves the space; the visible text is layered on top. Handles
264
- any style change (weight, size), single-line only. Best with `noWrap`.
265
- - `boldStable` — paints normal-weight text over a bold background clipped to the
266
- glyphs. Works on several lines, handles weight only.
267
-
268
- And `shrinkWrap` for the reverse problem: an element whose box is wider than the
269
- longest line it actually renders (a wrapped paragraph inside a flex or grid
270
- container). It measures and pins the width to that longest line.
204
+ `holdSpaceForStyle={{ fontWeight: "bold" }}` reserves the space with an
205
+ invisible copy in the target style — any style change, single-line only (best
206
+ with `noWrap`); `boldStable` handles weight only, on several lines. And
207
+ `shrinkWrap` for the reverse problem: an element wider than the longest line it
208
+ renders (a wrapped paragraph inside a flex or grid container) is pinned to that
209
+ line.
271
210
 
272
211
  ## Loading
273
212
 
274
213
  `loading` renders a shimmering skeleton in place of the content; `skeleton` is
275
214
  the same bar without the animation. The children stay in the DOM (hidden), so
276
- the block keeps the size the real text will have — pass the eventual text, not a
277
- placeholder, whenever it is known.
278
-
279
- The bar stands in for text and is painted with the text's own ink, so it is seen
280
- on whatever paper it lands on — a pale card, a filled one where the ink flips —
281
- without being told what that paper is. Where the surrounding ink is the wrong
282
- cue, `--skeleton-color` and `--skeleton-shimmer-color` (the moving band) take it
283
- over; navi declares neither, so both can be set on the element or on any
215
+ the block keeps the size the real text will have — **pass the eventual text,
216
+ not a placeholder**, whenever it is known.
217
+
218
+ The bar is painted with the text's own ink, so it is seen on whatever paper it
219
+ lands on without being told what that paper is. Where the surrounding ink is the
220
+ wrong cue, `--skeleton-color` and `--skeleton-shimmer-color` (the moving band)
221
+ take it over; navi declares neither, so both can be set on the element or on any
284
222
  container above it.
223
+
224
+ ## BadgeList
225
+
226
+ A row of badges that wraps. A plain one costs nothing — one element holding its
227
+ children as they are; only `max`, `fallback`, `maxLines` and `shrinkWrap` count
228
+ or measure. Once the list has something to decide (`max`, `fallback`,
229
+ `maxLines`), a `Badge` inside it does not render itself: it hands its props to
230
+ the list, which draws it. Two consequences:
231
+
232
+ - a `Badge` must be a **direct** child of the list — `<div><Badge /></div>`
233
+ takes the badge out of its wrapper;
234
+ - a badge's own `key` does not reach the badge the list draws, which is keyed
235
+ by position: reordering recreates the nodes rather than moving them.
236
+
237
+ Inside a `Picker`, the rows are capped by the picker's `maxLines` (the picker
238
+ turns its own clamp off: line-clamp never sees a wrapped flex row), and `max`
239
+ composes with it — the `+N` badge takes one of the `max` slots. A picker given
240
+ a `ui` draws that and only that, never its `placeholder`, so an empty
241
+ `BadgeList` there is a blank picker: pass the placeholder text as the list's
242
+ `fallback`.
243
+
244
+ ```jsx
245
+ <BadgeList fallback="Select skills…">
246
+ {selected.map((skill) => (
247
+ <Badge key={skill}>{skill}</Badge>
248
+ ))}
249
+ </BadgeList>
250
+ ```
251
+
252
+ Plain text is the right shape for it: it reads at the picker's own size, and the
253
+ box stays the same height empty and filled. It is drawn in the normal text
254
+ color, not the placeholder's — a caller's `ui` is never greyed as a placeholder.