@jsenv/navi 0.29.387 → 0.29.388

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.
@@ -184,7 +184,8 @@ sends nothing and changes no state, so the network is silent and
184
184
  `onRequestStateChange` — which reports what a request is doing — has no request
185
185
  to report. A run that declined and a run that was never mounted look identical.
186
186
  `debugScroll` is where that difference is visible; it carries the render window
187
- and the run's asking, which are one subject.
187
+ and the run's asking, which are one subject. It logs in navi's development build
188
+ only.
188
189
 
189
190
  ```jsx
190
191
  window.askLog = [];
@@ -251,6 +252,11 @@ Defaults are `{ GET: false, GET_MANY: ["POST"], GET_RANGE: ["POST", "DELETE"] }`
251
252
  | `GET_MANY: ["POST"]` | Whether a new item belongs in this list depends on filters, pagination, sort — the backend knows, the client does not. `DELETE` is excluded because the store already removes the item from every list. |
252
253
  | `GET_RANGE: ["POST", "DELETE"]` | A slice is a range of places: a row leaving the collection shifts every place after it, which the store cannot do. Add the verb that moves an item in or out of the collection — a `PATCH` that archives, one that changes an item's tab. |
253
254
 
255
+ A `rerunOn` given replaces the `GET` and `GET_MANY` defaults whole: a key it
256
+ leaves out reruns nothing, so `rerunOn: { GET_RANGE: ["POST", "DELETE", "PATCH"] }`
257
+ alone also turns off `GET_MANY`'s `["POST"]`. Only `GET_RANGE`, left out, keeps
258
+ its default. Restate every key the resource is read through.
259
+
254
260
  Adding `PUT`/`PATCH` to `GET_MANY` is the usual over-correction: it costs a
255
261
  request and a `loading` pass to obtain something the response already contained.
256
262
  When a `PATCH` "doesn't show", the fix is almost always server-side — return the
@@ -91,9 +91,9 @@ far from the wrapper:
91
91
  other axis to `auto`.
92
92
 
93
93
  Note: `overflow: hidden` on `<html>` would additionally create a new containing block,
94
- which could break `position: fixed` in edge cases. Any element that needs correct
95
- `position: fixed` behavior (like `<dialog>`) should be moved to `document.body` directly
96
- anyway (which `dialog.jsx` already does).
94
+ which could break `position: fixed` in edge cases. A dialog in the top layer is out of
95
+ reach of that: the top layer takes it out of every ancestor's containing block, which is
96
+ why navi's `Dialog` stays where it is written, in its opener's subtree.
97
97
 
98
98
  ### The wrapper is a net, not a fix: find what overflows
99
99
 
@@ -132,8 +132,9 @@ scrolls down to it when it opens.
132
132
  Fix: **place `<dialog>` as the first child of `<body>`**, before any scrollable content.
133
133
  `scrollIntoView` on an element already at the top of the document has no effect.
134
134
 
135
- In `dialog.jsx`, `showModal()` moves the dialog to `document.body` (prepending it)
136
- before opening for this reason.
135
+ This is advice for a `<dialog>` written by hand. navi's `Dialog` is not moved: it
136
+ renders in its opener's subtree (see
137
+ [css_architecture.md](./css_architecture.md#counting-children-what-navi-puts-in-your-tree)).
137
138
 
138
139
  ### Summary
139
140
 
@@ -713,12 +713,14 @@ too early and put back:
713
713
  hands out.
714
714
 
715
715
  **Only where the row owns the document**: nothing between the travelling box and
716
- the viewport may scroll or clip. A row inside a scroller of its own — a frame in
717
- an article, a panel beside other content — shares nothing with the document, and
716
+ the viewport may scroll. A row inside a scroller of its own — a frame in an
717
+ article, a panel beside other content — shares nothing with the document, and
718
718
  the offset there belongs to the page around it, which the reader never left; the
719
- travel leaves it alone. An `overflow: hidden` or `clip` on any ancestor is
720
- enough to put the row outside the document's scrollport, so a row that should
721
- give positions back and does not is worth looking at from that angle first.
719
+ travel leaves it alone. An `overflow: hidden` on any ancestor is enough to put
720
+ the row outside the document's scrollport — it holds an offset of its own even
721
+ without a scrollbar, whereas `overflow: clip` holds none and does not count — so
722
+ a row that should give positions back and does not is worth looking at from
723
+ that angle first.
722
724
 
723
725
  Pages that scroll inside themselves rather than scrolling the document are not
724
726
  concerned either way: each one brings its own scrollport, which goes away with
@@ -29,8 +29,11 @@ cache the app never has to write:
29
29
  - **A route action left behind is aborted, not reset.** Its value survives; only
30
30
  a run in flight is called off.
31
31
  - **Running a completed action is a no-op.** Coming back to `/games/abc` from
32
- the list sends nothing — `run()` on a `COMPLETED` action already has its data.
33
- Only `rerun()` goes back to the network (see [actions.md](./actions.md)).
32
+ the list sends nothing while the instance that read it is alive — `run()` on a
33
+ `COMPLETED` action already has its data. Only `rerun()` goes back to the
34
+ network (see [actions.md](./actions.md)). An instance nothing references any
35
+ more is asked again, with the store row drawn meanwhile; under the policy
36
+ that ask is answered from the store (below).
34
37
  - **A failed rerun keeps the previous value.** Only `errorSignal` and the
35
38
  running state move; what was on screen stays on screen.
36
39
 
@@ -109,7 +112,11 @@ was asked:
109
112
 
110
113
  Which actions the policy sees: those declaring a verb (`meta.verb`) — every
111
114
  action a `resource()` makes. A plain `createAction` may not touch the network
112
- at all, so it is left alone; give it `meta: { verb: "GET" }` to opt in.
115
+ at all, so it is left alone. `createAction(callback, { meta: { verb } })` opts
116
+ it into what the action layer holds — a control bound to a write verb is
117
+ read-only, a completed `"GET"` is not rerun — but its callback is still called
118
+ when it runs: answering from the store and settling with a `NetworkPolicyError`
119
+ are done by the callbacks a `resource()` wraps.
113
120
 
114
121
  ## Holding writes while reads go out
115
122
 
package/docs/pan_zoom.md CHANGED
@@ -73,8 +73,8 @@ Opt-in, and for the same reason its opposite is (see
73
73
  [`data-drag-on-contact`](./drag_interactions.md#a-finger-that-does-not-have-to-wait-data-drag-on-contact)):
74
74
  navi cannot see whether anything behind the surface scrolls. You know;
75
75
  say so. It spends the hold, though: a `longpress` declared beside a `pan` that
76
- waits asks one finger to answer two waits of the same length, and only one of
77
- them is answered.
76
+ waits asks one finger to answer two waits, and the surface's — the shorter one
77
+ — is the one answered.
78
78
 
79
79
  The wait is then asked before every pan, which is right while each finger
80
80
  landing there is genuinely ambiguous. Once the user has settled into the plan it
@@ -62,9 +62,10 @@ dismissing click must not also press what is under it — without dimming the
62
62
  page for it. `backdrop={false}` is for a popup that must not absorb at all.
63
63
 
64
64
  **It is what decides whether a popup is modal.** `Popover` is never modal,
65
- either layer. A `Dialog` is modal exactly when it has a wall: with one, a
66
- top-layer dialog is `showModal()`'d and the browser makes everything behind
67
- genuinely inert; without one it goes to the same top layer through the Popover
65
+ either layer, and neither is a `layer="local"` dialog. A top-layer `Dialog` is
66
+ modal exactly when it has a wall: with one it is `showModal()`'d and the
67
+ browser makes everything behind genuinely inert; without one it goes to the
68
+ same top layer through the Popover
68
69
  API (`popover="manual"`), placed against the screen just the same, over a page
69
70
  that stays live. `Popup` forwards `backdrop` in both modes, so which of popover
70
71
  or dialog the small-screen resolution picks says nothing about whether one
@@ -179,7 +180,12 @@ panel that closes on that press then reopens on the click that follows is a
179
180
  panel meant to stay open, blinking.
180
181
 
181
182
  ```jsx
182
- <SidePanel id="error_panel" signal={openCardIdSignal} closeByPressOutside>
183
+ <SidePanel
184
+ id="error_panel"
185
+ signal={openCardIdSignal}
186
+ backdrop={false}
187
+ closeByPressOutside
188
+ >
183
189
  …
184
190
  </SidePanel>
185
191
 
@@ -198,7 +204,7 @@ adds a whole thing to the popup's ground — the card, its count, its buttons
198
204
  and nothing in it is a dismissal.
199
205
 
200
206
  It reads a press the page hears, so it belongs with a popup that has no wall
201
- (`backdrop={false}`, or a popover): behind a wall, no card is pressable.
207
+ (`backdrop={false}`): behind a wall, no card is pressable.
202
208
 
203
209
  ### `pointer-events: none` and `inert` are not this
204
210
 
@@ -132,8 +132,9 @@ corners itself, or on the card inside it, reads the same.
132
132
 
133
133
  ## Same width, or a wider box
134
134
 
135
- `dialogSizeFromAnchor` makes the trigger's box a floor for the dialog;
136
- `dialogMaxWidth="var(--anchor-width)"` makes it the ceiling too. Together they
135
+ On a `Picker`, `dialogSizeFromAnchor` makes the trigger's box a floor for the
136
+ dialog; `dialogMaxWidth="var(--anchor-width)"` makes it the ceiling too (on a
137
+ `Dialog` the same props are `sizeFromAnchor` and `maxWidth`). Together they
137
138
  say "the same card, brought forward": the box travels and may grow in height,
138
139
  never in width. Left out, `dialogExpandX` lets the sheet take the room it has,
139
140
  and the width change is animated — the card keeps its scale inside a box
@@ -334,7 +335,8 @@ into the picture of the state it closes into.
334
335
 
335
336
  ## What it costs, and where the time goes
336
337
 
337
- Measured at CPU ×6 on the demo bench (`12_picker_card_demo.html#lift-bench`),
338
+ Measured at CPU ×6 on the demo bench
339
+ (`src/control/demos/picker/12_picker_card_demo.html#lift-bench`),
338
340
  a card of a real edit sheet's weight (≈215 elements, 13 fields, 62 picker
339
341
  nodes):
340
342
 
@@ -91,7 +91,7 @@ where that state lives:
91
91
 
92
92
  | what opens it | how |
93
93
  | ---------------------------------- | ------------------------------------------------------ |
94
- | a button | `command` / `commandfor` attributes, no `open` |
94
+ | a button | `command` / `commandFor` props, no `open` |
95
95
  | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
96
96
  | a state the app holds | `signal` — [below](#when-the-app-holds-the-open-state) |
97
97
  | where the user is | `navState`, or a route `stateSignal` given to `signal` |
@@ -99,7 +99,7 @@ where that state lives:
99
99
  ## A button opens it: the attributes
100
100
 
101
101
  ```jsx
102
- <Button command="--navi-open" commandfor="note-dialog">
102
+ <Button command="--navi-open" commandFor="note-dialog">
103
103
  Read the note
104
104
  </Button>
105
105
  <Dialog id="note-dialog">…</Dialog>
@@ -123,7 +123,7 @@ press:
123
123
  ## Something else opens it: `triggerNaviCommand`
124
124
 
125
125
  The attributes fire on every click of the element that carries them, and they
126
- cover more than "this button opens that dialog": `commandfor` says to whom,
126
+ cover more than "this button opens that dialog": `commandFor` says to whom,
127
127
  `value` says what it is about, `--navi-x:argument` says how. Before writing any
128
128
  JS here, check none of those is the answer — `triggerNaviCommand` is the last
129
129
  resort, not the general way to run a command.
@@ -209,7 +209,7 @@ closes the popup as usual.
209
209
 
210
210
  ```jsx
211
211
  <Row
212
- commandfor="row_menu"
212
+ commandFor="row_menu"
213
213
  interactions={{
214
214
  longpress: (event) =>
215
215
  triggerNaviCommand(event.target, "--navi-open", event),
@@ -225,7 +225,8 @@ own opening press from somebody else's comes from that gesture.
225
225
  The first argument is the command's **source** — the element it is triggered
226
226
  _from_. The target is resolved from it, in this order:
227
227
 
228
- 1. `commandfor="someId"` on the source,
228
+ 1. `commandFor="someId"` on the source (written to the DOM as the HTML
229
+ `commandfor` attribute, which is what is read),
229
230
  2. `navi-command-target="parent-control" | "child-control"`,
230
231
  3. the command's own fallback — for the popup commands, `closest("[aria-expanded]")`.
231
232
 
@@ -234,7 +235,7 @@ element itself as the source resolves to that popup: `closest()` starts at the
234
235
  element itself. That is the short form used above, and it is enough whenever the
235
236
  JS that decides already holds the popup's ref.
236
237
 
237
- To trigger from another element instead, give that element a `commandfor`
238
+ To trigger from another element instead, give that element a `commandFor`
238
239
  pointing at the popup's `id` — attribute-driven target resolution, JS-driven
239
240
  timing.
240
241
 
@@ -250,10 +251,11 @@ names none, and on whoever asked when nothing else says. In that order:
250
251
  3. **`detail.source`** — who asked. A button therefore opens the popup on
251
252
  itself, and a popup passed as its own source is its own anchor.
252
253
 
253
- For `Dialog` the anchor does nothing at all unless `sizeFromAnchor` is passed
254
- (then, and only then, it feeds the `--anchor-width`/`--anchor-height` CSS vars);
255
- for `Popover`, which really is positioned relative to its anchor, say what the
256
- anchor is:
254
+ For `Dialog` the anchor does nothing at all unless `sizeFromAnchor` asks for
255
+ it (it then feeds the `--anchor-width`/`--anchor-height` CSS vars) or
256
+ `animation="lifting"` does (the box the dialog comes out of, see
257
+ [popup_lift.md](./popup_lift.md)); for `Popover`, which really is positioned
258
+ relative to its anchor, say what the anchor is:
257
259
 
258
260
  ```jsx
259
261
  <Popover ref={popoverRef} anchor={rowRef}>
@@ -261,8 +263,9 @@ anchor is:
261
263
 
262
264
  `anchorCustomEventDetail="ignore"` drops the open's side of that order
263
265
  entirely, leaving the `anchor` prop alone — for a popover that must never be
264
- anchored to whatever opened it (`SidePanel` does this), and for a
265
- `sizeFromAnchor` dialog that must never be sized from it.
266
+ anchored to whatever opened it (`SidePanel` does this), and for a dialog that
267
+ must never be sized from it (`sizeFromAnchor`) or lifted out of it
268
+ (`animation="lifting"`).
266
269
 
267
270
  ## Opening it ON something
268
271
 
@@ -272,8 +275,8 @@ row is one dialog with two modes, and the press is the only thing that knows
272
275
  which one — so the press says it, with its own value:
273
276
 
274
277
  ```jsx
275
- <Button command="--navi-open" commandfor="radar-dialog">Nouveau radar</Button>
276
- <Button value={radar.id} command="--navi-open" commandfor="radar-dialog" />
278
+ <Button command="--navi-open" commandFor="radar-dialog">Nouveau radar</Button>
279
+ <Button value={radar.id} command="--navi-open" commandFor="radar-dialog" />
277
280
 
278
281
  <Dialog
279
282
  id="radar-dialog"
@@ -629,7 +632,7 @@ command once it succeeded.
629
632
  ```jsx
630
633
  // Stays open while save() runs, closes when it resolves, stays open if it
631
634
  // throws — with what was typed still there and the error on the button.
632
- <Button command="--navi-close" commandfor="note-dialog" action={save}>
635
+ <Button command="--navi-close" commandFor="note-dialog" action={save}>
633
636
  Save
634
637
  </Button>
635
638
  ```
@@ -689,12 +692,12 @@ the popup without closing it, and the error callout stays where the press was.
689
692
  The gestures that close a popup do not all mean the same thing, and that is on
690
693
  purpose:
691
694
 
692
- | gesture | what it means | who decides |
693
- | ------------------------------ | ------------- | --------------------------------- |
694
- | Escape | cancel | `escapeEffect="cancel"` (default) |
695
- | a press outside | close, keep | `pressOutside="close"` (default) |
696
- | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
697
- | `--navi-cancel` on a button | cancel | the button |
695
+ | gesture | what it means | who decides |
696
+ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------ |
697
+ | Escape | cancel | always, for a `Dialog`/`Popover`; `escapeEffect="cancel"` (the default) for a `Picker` |
698
+ | a press outside | close, keep | `pressOutside="close"` — the default of a `Dialog` and of a `Picker`; a bare `Popover` says `"ignore"` |
699
+ | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
700
+ | `--navi-cancel` on a button | cancel | the button |
698
701
 
699
702
  Escape says "forget it". It is the one gesture that has meant that everywhere,
700
703
  for as long as there have been dialogs, and navi keeps it that way. **A popup
@@ -757,7 +760,8 @@ empty.
757
760
 
758
761
  ### `escapeEffect="close"`, and why it is a last resort
759
762
 
760
- `escapeEffect="close"` makes Escape say what a click outside says. It exists,
763
+ `escapeEffect="close"` — a `Picker` prop; a `Dialog`/`Popover` has no such
764
+ switch — makes Escape say what a click outside says. It exists,
761
765
  and it is almost never what you want: it takes away the only key that undoes,
762
766
  and a popup with no way back is one people stop opening. Reach for a close
763
767
  cross first.
@@ -996,7 +1000,8 @@ one fill above.
996
1000
 
997
1001
  A press on another card is not an outside press once the card names the panel
998
1002
  with `data-navi-popup-inside` (see [popup_backdrop.md](./popup_backdrop.md)).
999
- The full example is `src/layout/demos/7_slot_demo.html`, "two side panels".
1003
+ The full example is `src/layout/demos/7_slot_demo.html`, "Two side-panel
1004
+ slots".
1000
1005
 
1001
1006
  ## A popup that loads data
1002
1007
 
@@ -1116,14 +1121,20 @@ half-typed form where it left them.
1116
1121
  "Closed" is two states, not one — never opened yet, and closed again after an
1117
1122
  opening — so the `mount` prop answers both at once:
1118
1123
 
1119
- | `mount` | before the first open | after a close |
1120
- | ----------------------------- | --------------------- | ------------- |
1121
- | `"always"` | mounted | mounted |
1122
- | `"from-first-open"` (default) | not mounted | mounted |
1123
- | `"while-opened"` | not mounted | not mounted |
1124
+ | `mount` | before the first open | after a close |
1125
+ | ----------------------------- | --------------------------- | ------------- |
1126
+ | `"always"` | mounted | mounted |
1127
+ | `"idle"` | mounted once the page idles | mounted |
1128
+ | `"from-first-open"` (default) | not mounted | mounted |
1129
+ | `"while-opened"` | not mounted | not mounted |
1124
1130
 
1125
1131
  `"always"` is for content something depends on before any opening: a value read
1126
1132
  off it, fields a surrounding form submits, a size measured from outside.
1133
+ `"idle"` is `"always"` minus the cost on the render that draws the page.
1134
+
1135
+ Whatever the value but `"while-opened"`, intent on the anchor — a pointer
1136
+ entering it, focus landing in it — builds the content ahead of the click that
1137
+ will open it.
1127
1138
 
1128
1139
  `"while-opened"` is what an uncontrolled field seeded from a `defaultValue`
1129
1140
  needs: without it, a popup reopened after the underlying value changed still
package/docs/resource.md CHANGED
@@ -93,14 +93,15 @@ const GAME = resource("game", {
93
93
  ```
94
94
 
95
95
  The callback receives the bound params merged with the range the list asks for
96
- (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
97
- as second argument — aborted when the list stops wanting those rows. It returns
98
- a range the way a `Content-Range` does: **`{ items, start, count }`** — these
99
- rows, at this place, out of that many. `start` may be omitted when the list
100
- asked for a positive one; `count` defaults to `start + items.length` (a source
101
- that does not know its total). The items are upserted on their way in, so the
102
- list draws store items — never copies of the JSON — and a request sent from a
103
- row is read back on that row.
96
+ (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and `{ signal }`
97
+ as second argument — a signal aborted when the list stops wanting those rows.
98
+ It returns a range the way a `Content-Range` does: **`{ items, start, count }`**
99
+ — these rows, at this place, out of that many. `start` may be omitted when the
100
+ list asked for one at or after `0` (a negative `start` counts back from the end,
101
+ and the source must then say where the slice landed); `count` defaults to
102
+ `start + items.length` (a source that does not know its total). The items are
103
+ upserted on their way in, so the list draws store items — never copies of the
104
+ JSON — and a request sent from a row is read back on that row.
104
105
 
105
106
  A row that must follow its **own fields** through a write reads them from the
106
107
  store rather than from the object it was handed:
@@ -131,9 +132,12 @@ this collection again after its screen went away finds it drawn and revalidates
131
132
  it rather than starting over
132
133
  ([list_refresh.md](./list_refresh.md#leaving-the-screen-and-coming-back)).
133
134
 
134
- What it does not give is membership: an item that leaves the collection stays on
135
- screen until the rows are asked for again. Give the screen its own way to ask
136
- (a refresh gesture, a `key` on the run).
135
+ Membership is what a re-read answers: a verb listed in `rerunOn.GET_RANGE`
136
+ (`["POST", "DELETE"]` by default) tells the run the collection moved, and it
137
+ asks again for the window it draws, rows staying on screen meanwhile. A write
138
+ that moves an item in or out through another verb — a `PATCH` that archives —
139
+ is added there, or `reader.invalidate()` is called; a `key` on the run is not the
140
+ way ([list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)).
137
141
 
138
142
  It reads a collection, so it lives on the resource itself (or on a
139
143
  `withParams()` of it), not on a relation.
@@ -411,14 +415,17 @@ rules, verified by `src/state/rest/tests/resource_graph_parent_rerun.test.js`:
411
415
  reruns the parent. Declare it explicitly with `dependencies` if you need it
412
416
  (see [`dependencies`](#dependencies-rerun-after-another-resource-writes)).
413
417
  - Within a relationship resource, the usual defaults still apply: its own
414
- `GET_MANY` reruns after its own `POST`; its `GET` is reset (not rerun) by its
415
- `DELETE`. Override per relation with `rerunOn`/`dependencies`, which every
416
- relationship method accepts.
418
+ `GET_MANY` reruns after its own `POST`. A `.one`/`.many` `GET` is reset (not
419
+ rerun) by its own `DELETE`, as a root `GET` is; a scoped `GET` is not reset —
420
+ its data simply resolves to nothing once the child is dropped. Override per
421
+ relation with `rerunOn`/`dependencies`, which every relationship method
422
+ accepts.
417
423
 
418
424
  Splitting a sub-resource out of a parent `PATCH` therefore changes the refresh
419
- graph: what used to be refreshed by the parent's own response is now refreshed
420
- only by these rules. When a parent field genuinely depends on a child mutation,
421
- say so with `dependencies` rather than relying on a rerun that will not happen.
425
+ graph: a parent field the parent's own response kept fresh is, once the relation
426
+ is a resource of its own, refreshed only by these rules. When a parent field
427
+ genuinely depends on a child mutation, say so with `dependencies` rather than
428
+ relying on a rerun that will not happen.
422
429
 
423
430
  ## `withParams()`: a scope with reruns of its own
424
431
 
@@ -132,8 +132,9 @@ relation was written for. Two situations, two answers:
132
132
  deserves a direction. This is the case the export exists for.
133
133
 
134
134
  A default has no direction (nothing says which of two arbitrary pages is
135
- "before" the other), so only directionless movements make sense there. Written
136
- relations, and `"none"`, always win over it.
135
+ "before" the other), so only directionless movements make sense there: navi's
136
+ `slide-*` and `cover-*` are written on the direction, and as the default they
137
+ play nothing. Written relations, and `"none"`, always win over it.
137
138
 
138
139
  ## When one navigation knows better
139
140
 
@@ -179,7 +180,8 @@ the same forms `defineRouteTransition` takes, plus `direction`. It overrides
179
180
  answers for. So:
180
181
 
181
182
  - `{ direction: "back" }` keeps the pair's movement and only turns it round;
182
- - `"zoom"` swaps the movement, keeping nothing else;
183
+ - `"zoom"` swaps the movement; the way round and the pace are still the
184
+ pair's;
183
185
  - `"none"` cuts, where the pair — or the default — would have played;
184
186
  - `{ duration: 500 }` re-times what was already going to play.
185
187
 
@@ -525,12 +527,13 @@ await page.goBack();
525
527
  The window is **one frame**: the hold is given back inside the view
526
528
  transition's callback, which the browser runs at its next rendering
527
529
  opportunity. `tests/route_transition_list_revisit/` walks it the wrong way on
528
- purpose in its last case — under a movement the list that comes back is the same
529
- DOM element ten times out of ten and asks for nothing, while the same walk
530
- without a movement is a real one — and the window measured there is a back at 0
531
- or 8ms losing every round trip, at 16ms losing only the first, and past 64ms
532
- losing none. No thumb moves in one frame; an automated click continues in the
533
- same millisecond. This is a testing trap, not a user-facing behaviour.
530
+ purpose in its last case — a back pressed the instant the URL changes — and
531
+ which side of that frame a cycle lands on belongs to the machine's load: under
532
+ a movement the list that comes back is either the element that never left,
533
+ asking for nothing, or one truly remounted, asking once, while the same walk
534
+ without a movement is a real one every time. No thumb moves in one frame; an
535
+ automated click continues in the same millisecond. This is a testing trap, not
536
+ a user-facing behaviour.
534
537
 
535
538
  ## The rest, briefly
536
539
 
@@ -538,6 +541,6 @@ same millisecond. This is a testing trap, not a user-facing behaviour.
538
541
  a per-relation `{ type, duration }` for one relation.
539
542
  - The URL leads: transitions play on navigations somebody else started (a
540
543
  `<Link>`, the back button, `history.back()`). Nothing here navigates.
541
- - A browser without view transitions (Firefox) navigates with a cut. The app
544
+ - A browser without view transitions navigates with a cut. The app
542
545
  must remain fully usable that way — which it is, if the transitions state
543
546
  relations rather than carry information.
package/docs/safe_area.md CHANGED
@@ -80,8 +80,8 @@ uneven writes `--navi-app-inset-left` / `-right` directly instead.
80
80
  In pixels: popup placement reads this value back from CSS to compute its own
81
81
  margins, and a custom property computes to a token stream rather than to a
82
82
  length, so `40rem` would arrive there as the string `"40rem"`. A non-px value
83
- still caps the popup's size (that part is pure CSS) but leaves the margins
84
- viewport-sized, and says so in the console.
83
+ still caps the popup's size (that part is pure CSS) but leaves its margins and
84
+ its placement viewport-sized, and says so in the console.
85
85
 
86
86
  Every popup follows: `Dialog`, `Popover`, and everything built on them
87
87
  (`Picker`, `Select`…). It is a ceiling and nothing more — on a screen narrower
@@ -98,11 +98,13 @@ Two ways NOT to get this:
98
98
  popovers would keep sizing themselves against 1500px;
99
99
  - setting `--dialog-max-width` on `.navi_dialog` from the app. It is a
100
100
  `--component-*` token, declared on the element (see
101
- [css_architecture.md](./css_architecture.md#--navi--vs---component--where-the-override-has-to-go)),
102
- so components that write it themselves outrank an app rule of lower
103
- specificity — `.navi_picker[aria-haspopup="dialog"] .navi_dialog` does exactly
104
- that, and the app's cap silently disappears for every picker. It is also the
105
- knob a single popup uses to ask for a specific size, not a ceiling:
101
+ [css_architecture.md](./css_architecture.md#--navi--vs---component--where-the-override-has-to-go)):
102
+ every dialog resets it on itself in an unlayered rule, so that a nested
103
+ dialog does not inherit its parent's size, and a `maxWidth` prop writes it
104
+ inline (a `Picker` under `dialogSizeFromAnchor` does) — an app rule of the
105
+ same specificity does not reliably win, and the cap silently disappears. It
106
+ is also the knob a single popup uses to ask for a specific size, not a
107
+ ceiling:
106
108
  `--navi-app-max-width` feeds `--dialog-maxmax-width`, the hard ceiling _under_
107
109
  that knob, so a popup that genuinely needs its own `maxWidth` can still say
108
110
  so without escaping the app's screen.
@@ -154,10 +156,12 @@ Beware of making that container scrollable by accident — see
154
156
  `var(--navi-safe-area-inset-bottom)` in any rule. It is always declared, whether
155
157
  or not the app ever mounts a bar.
156
158
 
157
- Reading it from **JS** takes a probe: an unregistered custom property keeps its
158
- `calc()` unresolved through `getComputedStyle`. Give a hidden box
159
- `height: var(--navi-safe-area-inset-bottom)` and measure it — see
160
- `src/layout/demos/fixed_bar/keyboard.html`.
159
+ From **JS**, the four `--navi-safe-area-inset-*` are registered as lengths
160
+ (`@property`, in `safe_area.js`), so
161
+ `getComputedStyle(document.documentElement).getPropertyValue("--navi-safe-area-inset-bottom")`
162
+ gives pixels. The level-1 `--navi-app-inset-*` are not registered and keep their
163
+ `calc()` unresolved there: give a hidden box `height: var(…)` and measure it —
164
+ see `src/layout/demos/fixed_bar/keyboard.html`.
161
165
 
162
166
  ### Putting something new into it
163
167
 
@@ -182,24 +186,36 @@ truth:
182
186
 
183
187
  ## The trap: which viewport
184
188
 
185
- Three heights are in play and they are not the same one.
189
+ Three heights are in play and they are not the same one — and which of them
190
+ the keyboard moves depends on the browser.
186
191
 
187
- | what | shrinks when the keyboard opens |
188
- | -------------------------------- | ------------------------------- |
189
- | `window.innerHeight` / `100dvh` | no |
190
- | `visualViewport.height` | yes |
191
- | `--navi-vvh` (tracks the visual) | yes |
192
+ | what | shrinks when the keyboard opens |
193
+ | -------------------------------- | ----------------------------------------------- |
194
+ | `window.innerHeight` / `100dvh` | no |
195
+ | `visualViewport.height` | yes, except where the keyboard overlays (below) |
196
+ | `--navi-vvh` (tracks the visual) | same as `visualViewport.height` |
197
+
198
+ Wherever the browser has the VirtualKeyboard API (Chromium), navi makes the
199
+ keyboard overlay the page rather than shrink it (`src/layout/virtual_keyboard.js`):
200
+ no viewport shrinks, and the keyboard arrives as `--navi-keyboard-inset-bottom`
201
+ (`env(keyboard-inset-height)`), which `--navi-app-inset-bottom` adds. Firefox,
202
+ Safari, and an app that called `disableVirtualKeyboardOverlay()` shrink the
203
+ visual viewport instead, and `--navi-keyboard-inset-bottom` stays 0. Either way
204
+ `--navi-app-height` and the popup ceilings answer the part of the screen left
205
+ visible.
192
206
 
193
207
  `position: fixed` — so every `FixedBar` — is laid out against the **layout**
194
- viewport. A bottom bar therefore stays at the bottom of a window the keyboard is
195
- covering: it ends up _behind_ the keyboard, and no inset says so, because
196
- nothing reduced the layout viewport.
208
+ viewport. Where the visual viewport is what shrinks, a bottom bar therefore
209
+ stays at the bottom of a window the keyboard is covering: it ends up _behind_
210
+ the keyboard, and no inset says so, because nothing reduced the layout viewport.
211
+ Where the keyboard overlays, the bar is pinned to `--navi-app-inset-bottom`,
212
+ which counts it, and sits above it.
197
213
 
198
214
  The consequence for anything measuring against the insets: mix the two families
199
215
  and you get a drift that only appears with a keyboard open. `getBoundingClientRect`
200
216
  is in layout-viewport coordinates, so what is compared to it must be too
201
- (`100dvh`), while `--navi-app-*` derives from `--navi-vvh` because what navi
202
- _sizes_ must fit what is actually visible.
217
+ (`100dvh`), while `--navi-app-*` derives from `--navi-vvh` (plus the keyboard
218
+ inset) because what navi _sizes_ must fit what is actually visible.
203
219
 
204
220
  `src/layout/demos/fixed_bar/keyboard.html` puts all of these on screen at once
205
221
  and turns the bottom bar's number red when it goes under the keyboard. On a
package/docs/scroll.md CHANGED
@@ -47,7 +47,7 @@ Two shapes, and they do not behave the same:
47
47
  | `header` / `footer` alone | the container itself scrolls, and they are `position: sticky` at its edges — the content scrolls under them |
48
48
  | a `body` as well | the container becomes a flex column, its own overflow turns to `hidden`, and the **body is the only thing that scrolls**; header and footer sit outside it (`position: static`, `flex-shrink: 0`) |
49
49
 
50
- Two consequences worth knowing before fighting them:
50
+ Three consequences worth knowing before fighting them:
51
51
 
52
52
  - the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
53
53
  leaves the footer right under it rather than pushed to the bottom of a box it
@@ -61,7 +61,8 @@ Two consequences worth knowing before fighting them:
61
61
  them — positioned or not. Write `style={{ "--box-header-z-index": "auto" }}`
62
62
  (`--box-footer-z-index` likewise) at the call site that needs the opposite: a
63
63
  badge or a stamp overflowing a row is otherwise sliced by a header it never
64
- scrolls under. `isolation: isolate` on the box keeps either value local to it.
64
+ scrolls under. The box is `isolation: isolate` already, so either value stays
65
+ local to it.
65
66
  See `docs/z_index.md` and `src/box/demos/9_scrollable_z_index_demo.html`.
66
67
 
67
68
  Padding belongs on the parts, not on the scrolling box: padding on a scroller
@@ -237,10 +238,11 @@ A dialog is already bounded by the room its container leaves it
237
238
  (`--dialog-maxmax-height`), so a `maxHeight` is only for making it smaller than
238
239
  that.
239
240
 
240
- `dialog.jsx` deliberately declares no `overflow` of its own: a modal dialog
241
- would inherit `auto` from the UA stylesheet and a `layer="local"` one gets
242
- nothing, so without a scrolling rule its `max-height` would only decide how big
243
- the box looks while the content kept painting straight through it.
241
+ `dialog.jsx` deliberately declares no `overflow` of its own: the
242
+ `[data-scrollable]` the dialog carries is its scrolling rule. A modal dialog
243
+ would get `auto` from the UA stylesheet anyway, but a `layer="local"` one gets
244
+ nothing, and without that rule its `max-height` would only decide how big the
245
+ box looks while the content kept painting straight through it.
244
246
 
245
247
  ### `scrollCapture`
246
248
 
@@ -309,7 +311,7 @@ So: nothing scrollable between the cap and the slides. A `<Box body>` around
309
311
  them is a scroller (see the table at the top of this file) — and so is a bare
310
312
  `overflow="auto"` on a wrapper. The dialog keeps a shared `header` if the tabs
311
313
  are shared, with an explicit `flexShrink="0"` since the rule that gives it for
312
- free lives inside `[data-scrollable]`.
314
+ free applies only next to a `body`.
313
315
 
314
316
  **Padding goes on the slide** — or on its parts, since the slide is now the
315
317
  scroller (see the top of this file) — but never on the container nor on
@@ -393,8 +395,8 @@ browser paints first — what a phone screen shows, plus a few — and `after` f
393
395
  the paint on. The switch waits for the paint itself, not for an effect: preact
394
396
  runs a component's pending effects early when that component renders again,
395
397
  and something always re-renders before a popup has painted. Do not rebuild
396
- this by hand with a `useEffect` that widens a slice — that is the thing it
397
- replaces, and it fails for that reason. The runs ask their source for `after`
398
+ this by hand with a `useEffect` that widens a slice: that effect is one preact
399
+ runs early, and it fails for that reason. The runs ask their source for `after`
398
400
  rows from the start, so the smaller first window costs no second request.
399
401
 
400
402
  ### Doing it well