@jsenv/navi 0.29.142 → 0.29.144

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.
@@ -112,6 +112,9 @@ consistency across the app, not from any single call site.
112
112
  overrides, in that preference order), and what a popup inherits from the
113
113
  element that opened it: which tokens a surface takes back, the color
114
114
  keywords and their `-mix` ratios, and why a ratio is a `:root` knob only.
115
+ Read its first section before writing any `import.meta.css`: the build parses
116
+ that css, and a `${}` the build cannot read costs the whole stylesheet its
117
+ comments, its transpilation, its `url()` assets and its minification.
115
118
  - `docs/safe_area.md` — where the app is in the window and what covers it:
116
119
  the two inset families (`--navi-app-inset-*` for what is pinned to an edge,
117
120
  `--navi-safe-area-inset-*` for what flows inside), how an app declares itself
@@ -153,6 +156,16 @@ consistency across the app, not from any single call site.
153
156
  given a `ui` draws no placeholder of its own. Read it before putting a
154
157
  `BadgeList` in a picker's `ui`, before wrapping its fallback in a `Badge`,
155
158
  and before setting `maxLines` on the list rather than on the picker.
159
+ - `docs/state_binding.md` — the rule the whole API rests on: state navi shows is
160
+ BOUND (`signal`), not copied back by a callback. The three shapes an
161
+ application ever has to say (`signal` / `action` / `command` + `commandFor`),
162
+ the four ways the same mistake is written (`onCurrentChange`, `uiAction`,
163
+ `onClick` and `onOpen`/`onClose` all writing a signal), why a binding is not
164
+ merely shorter (it is written by every gesture, it moves the component when
165
+ written, and it goes through the interaction gate), what is left for the
166
+ callback, and what to conclude when the state being wired has no prop for it —
167
+ a parallel copy, or a binding navi is missing. Read it before writing any
168
+ handler whose body only assigns state.
156
169
  - `docs/control_value.md` — who holds a control's value: nobody, a bound
157
170
  `signal` (two-way, in both directions), or you (`value`/`checked`). What
158
171
  `signal` + `defaultValue` says, what a signal holds for each kind of control,
@@ -282,9 +295,13 @@ consistency across the app, not from any single call site.
282
295
  is given back the offset it was read at, and only when the row owns the
283
296
  document scroll), a back arrow that stays inside the app (`useCanNavBack()` /
284
297
  `navBack({ fallback })`), and the few cases where tabs are legitimately not
285
- URLs. Read it before writing any routing code — the
286
- position of the user belongs in the URL by default, and that decision is
287
- not retrofittable.
298
+ URLs. It also holds the middle answer — a position READ from the URL and
299
+ restored on reload without a route and without an entry per step: a search
300
+ param signal handed to a `SlideContainer`, walked to rather than jumped to,
301
+ and `history: "push"` for the states whose values ARE places (with the write
302
+ that says otherwise, `signal.set(v, { history })`). Read it before writing any
303
+ routing code — the position of the user belongs in the URL by default, and
304
+ that decision is not retrofittable.
288
305
  - Source code and demos on GitHub:
289
306
  https://github.com/jsenv/core/tree/main/packages/frontend/navi — where to go
290
307
  when the built export's JSDoc doesn't answer the question (see "Where the
package/docs/actions.md CHANGED
@@ -107,10 +107,47 @@ obvious fix does not work — a `useEffect` in that same component that would
107
107
  run it was about to start is exactly what the suspension waits for. So either
108
108
  the action is started **before** anything reads it — bound where the screen is
109
109
  decided: a `routeAction`, a `<Button action>`, the `action` of the `<Link>` one
110
- came in by — or the component owns its request and does not suspend on it: a
111
- folding panel, a slice a button asks for. That one reads `action.dataSignal` and
112
- `action.errorSignal` (or `useActionStatus`) directly, `run()`s the action from an
113
- effect, and draws its own skeleton until the data is there.
110
+ came in by — or the component owns its request: a folding panel, a slice a
111
+ button asks for. That second one is `{ run: true }`, said in the same line that
112
+ reads the data:
113
+
114
+ ```jsx
115
+ // asks for it if nobody did, from the render that reads it
116
+ const [members] = useAsyncData(membersAction, { run: true });
117
+ ```
118
+
119
+ It starts the action from the render rather than from an effect, which is what
120
+ makes it work at all: a suspended component has no effects, so a `run()` written
121
+ in one would be waiting for itself. Everything else is unchanged — `loading` and
122
+ `error` mean what they mean everywhere, delegated by default, handled inline
123
+ when asked for — and running an action twice costs nothing, so nothing has to
124
+ be guarded.
125
+
126
+ **`{ run: true }` is the fallback, not the shape to reach for.** A route action
127
+ is asked for when the ADDRESS changes, with everything else that address needs;
128
+ this one cannot start before the component that draws it exists, which is a
129
+ render late at best and a gesture late in a popup. So it is for data nothing
130
+ else can ask for — a parameter chosen inside the component and dying with it —
131
+ and it says so at the call site, where a hand-written `useEffect` + `run()` said
132
+ nothing at all.
133
+
134
+ Outside a component — a request the app owns rather than a component —
135
+ `actionRunEffect(action, () => …)` is that same run declared once: it runs on
136
+ the first truthy params, reruns when they change, aborts when they go false. It
137
+ is the very machinery `routeAction` is built on.
138
+
139
+ **Which of the two it is, is decided by the parameter, not by what draws it.**
140
+ If the parameter is in the address — a path param, a search param bound to a
141
+ `stateSignal` — the data belongs to the screen, and it is a `routeAction`, even
142
+ when a popup is the only thing that draws it. The component owns its request
143
+ only when the parameter is chosen inside it and dies with it. A popup is never
144
+ by itself a reason to own one: being open can be bound too — to a signal, to the
145
+ history entry, to the address — and the data of a popup that binds it is a route
146
+ action like any other (see
147
+ [popup_open.md](./popup_open.md#a-popup-that-loads-data)). Getting this wrong is
148
+ invisible at the call site and expensive on screen: the request then leaves with
149
+ the gesture instead of with the screen, one waterfall behind everything else the
150
+ address needed.
114
151
 
115
152
  `{ onLoad }` is what the screen does with the data **once, when it becomes
116
153
  known** — seed the fields someone is about to edit, focus something, remember
@@ -268,6 +305,9 @@ changes do exactly that).
268
305
 
269
306
  ## See also
270
307
 
308
+ - [state_binding.md](./state_binding.md) — `signal`, `action` or
309
+ `command` + `commandFor`: the three shapes, and the callback that only
310
+ remembers
271
311
  - [resource.md](./resource.md) — actions created from REST callbacks, and the
272
312
  store behind them
273
313
  - [resource_with_params.md](./resource_with_params.md) — `withParams()` and
@@ -243,7 +243,10 @@ that is a navi control goes through the same state controller, and the same
243
243
 
244
244
  `SlideContainer` takes one too, though it is layout rather than a control: the
245
245
  area it shows is a piece of state like any other, and binding it is what lets
246
- something else read where the slides are — or move them by writing it.
246
+ something else read where the slides are — or move them by writing it. Bind it
247
+ to a `stateSignal` a route declares as a search param and the area is in the
248
+ address, with no route per slide — see
249
+ [navigation.md](./navigation.md#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from).
247
250
 
248
251
  Inside a `List selectable` you can bind the list, or give each `List.Item` its
249
252
  own `selected` — but not expect the two to arbitrate. An item that declares
@@ -348,6 +351,9 @@ directions; it just has nothing extra to say.
348
351
 
349
352
  ## See also
350
353
 
354
+ - [state_binding.md](./state_binding.md) — the same rule beyond controls:
355
+ a `SlideContainer`'s area, a popup being open, the position in the app — all
356
+ bound rather than copied back by a callback
351
357
  - [form_changed.md](./form_changed.md) — what a form makes of each of these:
352
358
  which fields it counts as already answered, and when it sends nothing
353
359
  - [control_object.md](./control_object.md) — several controls reading as one
@@ -10,6 +10,224 @@ Navi components are styled through a combination of CSS custom properties (varia
10
10
 
11
11
  ---
12
12
 
13
+ ## Where CSS lives: `import.meta.css`
14
+
15
+ A navi component declares its stylesheet with `import.meta.css`, and the build
16
+ **parses that css**. Everything below depends on it staying parseable.
17
+
18
+ ```js
19
+ const css = /* css */ `
20
+ .navi_button {
21
+ height: var(--button-height);
22
+ }
23
+ `;
24
+ import.meta.css = css;
25
+ ```
26
+
27
+ The `css` constant may be declared next to the assignment or anywhere in the same
28
+ module — the build follows the name to where it is declared. What it cannot
29
+ follow is a name coming from **another** module: `import.meta.css = importedCss`
30
+ is read only when the constant belongs to the module doing the assignment.
31
+
32
+ **Keep the assignment in a render, not at module scope.** A component's
33
+ stylesheet is adopted when the component first renders, so a page that never
34
+ renders it never carries it, and a bundler that sees no caller drops the css
35
+ along with the code. `import.meta.css = css` at the top level of a module makes
36
+ the sheet a module side effect: it lands on every page importing the module, for
37
+ a component that may never appear, and nothing can shake it out.
38
+
39
+ That is also how a stylesheet shared between components is written — a module of
40
+ its own, exposing an install function its callers run from their render:
41
+
42
+ ```js
43
+ // input_css.js
44
+ const inputCss = /* css */ `
45
+ @layer navi {
46
+ .navi_input {
47
+ /* ... */
48
+ }
49
+ }
50
+ `;
51
+ // Keyed on this module, so the controls drawn as a .navi_input box share one
52
+ // stylesheet instead of each carrying a copy.
53
+ export const installInputCss = () => {
54
+ import.meta.css = inputCss;
55
+ };
56
+ ```
57
+
58
+ ```js
59
+ // textarea.jsx
60
+ import { installInputCss } from "./input_css.js";
61
+
62
+ export const Textarea = (props) => {
63
+ installInputCss();
64
+ import.meta.css = css; // this component's own css, and nothing else
65
+ // ...
66
+ };
67
+ ```
68
+
69
+ Note the setter is keyed by module: **two assignments in one module do not add
70
+ up**, the second replaces the first. One `import.meta.css` per module — which is
71
+ why the shared sheet has to live in a module of its own rather than be
72
+ concatenated into the component's.
73
+
74
+ The exception is a module that is a side effect by design — `navi_css_vars.js`
75
+ and the sheets it pulls in are the app's tokens, always needed, and assign at
76
+ module scope on purpose.
77
+
78
+ ### `${}` blinds the whole stylesheet
79
+
80
+ A substitution the build cannot read makes the **entire** template opaque — not
81
+ just the line it sits on — and everything below is lost for all of it:
82
+
83
+ - **comments ship to production**, with every space and newline
84
+ - **nothing is transpiled**: no nesting lowering, no prefixing, no fallback for
85
+ the browsers the app targets
86
+ - **`url("./icon.svg")` is never seen**: the file is not part of the build, not
87
+ copied, not hashed, and the url resolves against the document instead of the
88
+ module — it 404s in production
89
+ - **nothing is checked**: an unclosed brace or an invalid value reaches the
90
+ browser instead of failing the build
91
+ - **nothing is minified**
92
+
93
+ So a `${}` is not a small convenience: it opts a component's whole stylesheet
94
+ out of the build. The one shape that survives is a substitution standing exactly
95
+ **where a css value stands** — inside a rule block, after the `:` of a
96
+ declaration, not in a string, not in `url()`, not in a selector, a property name
97
+ or an at-rule prelude:
98
+
99
+ ```js
100
+ // read, transformed, and the expression put back
101
+ import.meta.css = /* css */ `
102
+ .panel {
103
+ transition: translate ${SETTLE_DURATION_MS}ms ease-out;
104
+ }
105
+ `;
106
+ ```
107
+
108
+ Anything else ships verbatim, silently. Do not rely on the distinction: write css
109
+ without substitutions.
110
+
111
+ ### Writing css without `${}`
112
+
113
+ **A value the JS knows → a custom property.** The css stays static, and the value
114
+ changes without building a new stylesheet:
115
+
116
+ ```js
117
+ // avoid
118
+ const setPanelWidth = (width) => {
119
+ import.meta.css = `.panel { width: ${width}; }`;
120
+ };
121
+
122
+ // prefer
123
+ import.meta.css = /* css */ `
124
+ .panel {
125
+ width: var(--panel-width, 300px);
126
+ }
127
+ `;
128
+ const setPanelWidth = (element, width) => {
129
+ element.style.setProperty("--panel-width", width);
130
+ };
131
+ ```
132
+
133
+ **An attribute or class name held in a JS constant → write it out in the css.**
134
+ This is the most common way navi's own stylesheets used to go blind: a constant
135
+ exists because JS sets the attribute, and the css then reads it through a
136
+ substitution. Keep the constant for the JS side and write the selector literally
137
+ — it is one string, and it buys back the whole stylesheet:
138
+
139
+ ```js
140
+ const SWIPE_AXES_ATTRIBUTE = "data-swipe";
141
+
142
+ // avoid
143
+ import.meta.css = `[${SWIPE_AXES_ATTRIBUTE}="x"] { touch-action: pan-y; }`;
144
+
145
+ // prefer
146
+ import.meta.css = /* css */ `
147
+ [data-swipe="x"] {
148
+ touch-action: pan-y;
149
+ }
150
+ `;
151
+ element.setAttribute(SWIPE_AXES_ATTRIBUTE, "x");
152
+ ```
153
+
154
+ **A selector you did not want to repeat → nesting.** Reaching into JS for a name
155
+ to avoid typing a selector twice trades a whole stylesheet for a little
156
+ repetition; `&` removes the repetition without leaving css:
157
+
158
+ ```js
159
+ // prefer
160
+ import.meta.css = /* css */ `
161
+ .navi_button {
162
+ color: black;
163
+ &[data-loading] {
164
+ opacity: 0.5;
165
+ }
166
+ }
167
+ `;
168
+ ```
169
+
170
+ **A variant → a data attribute, both branches written out.** A condition in JS
171
+ picking a declaration hides the css; a condition picking an attribute does not:
172
+
173
+ ```js
174
+ // avoid
175
+ import.meta.css = `.badge { color: ${tone === "danger" ? "red" : "blue"}; }`;
176
+
177
+ // prefer
178
+ import.meta.css = /* css */ `
179
+ .badge {
180
+ color: blue;
181
+ &[data-tone="danger"] {
182
+ color: red;
183
+ }
184
+ }
185
+ `;
186
+ ```
187
+
188
+ **A block of declarations repeated in several rules → a selector list.** A css
189
+ fragment held in a JS constant and interpolated into three rules is three blind
190
+ stylesheets; the same declarations under one selector list are css:
191
+
192
+ ```js
193
+ // prefer
194
+ import.meta.css = /* css */ `
195
+ :root,
196
+ .navi_popover,
197
+ .navi_dialog {
198
+ --navi-color-hint: color-mix(
199
+ in srgb,
200
+ currentColor var(--navi-color-hint-mix),
201
+ transparent
202
+ );
203
+ }
204
+ `;
205
+ ```
206
+
207
+ **A whole stylesheet shared between modules → a module exposing an install
208
+ function**, as shown at the top of this section.
209
+
210
+ ### Beware when moving a shared sheet out
211
+
212
+ Splitting a chunk out of a template changes **cascade order**: the extracted
213
+ sheet is adopted by the install call, which runs before the component's own
214
+ assignment, so it now comes _before_ that sheet instead of after it. That matters only when the two carry
215
+ the same selector at the same specificity and in the same layer — check for that
216
+ before splitting. In navi, `popup_css.js` and `surface_text_css.js` overlap
217
+ `.navi_dialog` and `.navi_popover` with dialog's and popover's own css, which is
218
+ why they are still concatenated.
219
+
220
+ ### The build target has to match the css
221
+
222
+ The css is transpiled for the runtimes the build targets, so a target older than
223
+ what the css uses is silently lowered. `light-dark()` under a target below Chrome
224
+ 123 becomes `var(--lightningcss-light, …) var(--lightningcss-dark, …)`, an
225
+ invalid declaration — the color is simply not applied. navi's own build targets
226
+ Chrome 123 because navi's css uses `light-dark()`; an app targeting older
227
+ browsers must not use css newer than its target.
228
+
229
+ ---
230
+
13
231
  ## Layer structure
14
232
 
15
233
  ```
@@ -41,7 +259,7 @@ This means an app can override a Navi default from its own unlayered CSS, withou
41
259
  }
42
260
  ```
43
261
 
44
- Targeting the same element is not a detail — see [`--navi-*` vs `--component-*`](#--navi-vs---component-where-the-override-has-to-go) below.
262
+ Targeting the same element is not a detail — see [`--navi-*` vs `--component-*`](#--navi--vs---component--where-the-override-has-to-go) below.
45
263
 
46
264
  ### Why actual rules stay outside any layer
47
265
 
@@ -3,6 +3,23 @@
3
3
  How to build navigation with `@jsenv/navi`: declaring routes, rendering them,
4
4
  linking to them, and turning them into tabs.
5
5
 
6
+ - [The rule that decides everything else: the position belongs in the URL](#the-rule-that-decides-everything-else-the-position-belongs-in-the-url)
7
+ - [Declaring routes](#declaring-routes)
8
+ - [A section is allowed to be a route of its own](#a-section-is-allowed-to-be-a-route-of-its-own)
9
+ - [Which values a param accepts](#which-values-a-param-accepts)
10
+ - [An address that only sends elsewhere](#an-address-that-only-sends-elsewhere)
11
+ - [Search params](#search-params)
12
+ - [Rendering routes](#rendering-routes)
13
+ - [Loading data](#loading-data)
14
+ - [Links and tab rows](#links-and-tab-rows)
15
+ - [The back arrow: `navBack`](#the-back-arrow-navback)
16
+ - [Tabs that travel: `RouteTravel`](#tabs-that-travel-routetravel)
17
+ - [Where a navigation lands: the scroll](#where-a-navigation-lands-the-scroll)
18
+ - [Creating something, then editing it](#creating-something-then-editing-it)
19
+ - [Tabs that are not routes](#tabs-that-are-not-routes)
20
+ - [A `SlideContainer` in the URL: a position that is not a place one came from](#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from)
21
+ - [A state whose values ARE places: `history: "push"`](#a-state-whose-values-are-places-history-push)
22
+
6
23
  ## The rule that decides everything else: the position belongs in the URL
7
24
 
8
25
  Where the user is — which section, which tab, which sub-page — is state. Put it
@@ -21,8 +38,10 @@ retrofitted later:
21
38
  So the default shape of a tab row is routes: `<Nav>` + `<Link route>` +
22
39
  `<RouteTravel>`. `SlideContainer` is the exception, not the starting point — see
23
40
  [Tabs that are not routes](#tabs-that-are-not-routes) for the cases that
24
- genuinely are one, and for the middle answer: a position READ from the URL and
25
- restored on reload, without a route and without a history entry per step.
41
+ genuinely are one, and
42
+ [A `SlideContainer` in the URL](#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from)
43
+ for the middle answer: a position READ from the URL and restored on reload,
44
+ without a route and without a history entry per step.
26
45
 
27
46
  ## Declaring routes
28
47
 
@@ -289,6 +308,11 @@ Declared on the **root route**, a search param is a position that holds wherever
289
308
  one is in the application — a view mode that survives moving from page to page.
290
309
  Declared on one route, it exists only there.
291
310
 
311
+ Writing it AMENDS the history entry one is on rather than stacking a new one: a
312
+ param that qualifies a screen is not a place, and one entry per write turns a
313
+ single back-press into as many as the user moved. A state whose values ARE
314
+ places says so — see [`history: "push"`](#a-state-whose-values-are-places-history-push).
315
+
292
316
  ## Rendering routes
293
317
 
294
318
  `<Route>` is the only primitive. With `children` it is a container that renders
@@ -575,9 +599,9 @@ match at once; the first matching branch wins).
575
599
  ## Tabs that are not routes
576
600
 
577
601
  `SlideContainer` holds slides that replace one another in one box, with the same
578
- gestures and the same travelling bar, and — unless it is given a `urlParam`, see
579
- below — nothing written to the URL. Use it when the position genuinely is not a
580
- place one should be able to link to:
602
+ gestures and the same travelling bar, and — unless the signal it is bound to is
603
+ one the URL holds, see below — nothing written to the URL. Use it when the
604
+ position genuinely is not a place one should be able to link to:
581
605
 
582
606
  - the steps of a wizard, or the screens of a picker, inside a dialog or a popover
583
607
  — a popup is promoted to the browser's top layer, so no container can hold two
@@ -604,7 +628,7 @@ page. It reads which slide is on screen from the container itself, and its bar
604
628
  follows the slides, a finger dragging them included. `<Link slide>` has no href
605
629
  and behaves like a button: this is not a link to anywhere.
606
630
 
607
- ### The middle answer: a position in the URL that is not a place one came from
631
+ ### A `SlideContainer` in the URL: a position that is not a place one came from
608
632
 
609
633
  "Should a link be able to open the app on this?" has a third answer, and a wizard
610
634
  is exactly it: **yes for reading and for reloading, no for history.** The step one
@@ -615,48 +639,111 @@ and it should NOT stack an entry per step, because the back arrow of a form mean
615
639
  back-press into four, and walk the reader backwards through a form they thought
616
640
  they had left.
617
641
 
618
- Neither pure answer fits: routes would want one route per step, a real navigation
619
- per move (so a push per move), and the walk's own rules — a step held until it is
620
- answered, a confirmation reachable only by publishing — re-expressed as route
621
- guards. A plain `SlideContainer` writes nothing at all.
642
+ There is nothing to invent for it: a search param already IS a position in the
643
+ URL that replaces rather than pushes ([Search params](#search-params)). Declare
644
+ the step as one, and hand its signal to the container:
622
645
 
623
- `urlParam` is that middle answer: the container owns one search param, writes
624
- where it stands into it **by replacement**, and opens on what it names.
646
+ ```js
647
+ const stepSignal = stateSignal("when", {
648
+ id: "step",
649
+ oneOf: ["when", "where", "who", "recap", "done"],
650
+ // the step qualifies THIS visit, not the screen: a link built to the editor
651
+ // does not inherit the step one happens to be on, and it goes back to the
652
+ // default when the route stops matching
653
+ weak: true,
654
+ });
655
+ export const ALERT_EDIT_ROUTE = route("/alerts/:alertId/edit", {
656
+ searchParams: { step: stepSignal },
657
+ });
658
+ ```
625
659
 
626
660
  ```jsx
627
- <SlideContainer id="alert_editor" signal={stepSignal} urlParam="step">
661
+ <SlideContainer signal={stepSignal}>
628
662
  ```
629
663
 
630
- Two things it does that a `useEffect` calling `history.replaceState` beside the
631
- container cannot, and they are the reason it lives inside:
632
-
633
- - it writes the travels that HAPPENED. A travel a lock refused, or one the caller
634
- refused late, never reaches the address — or is written back when it does;
635
- - it READS the param through the walk rather than jumping to it. The address
636
- comes from outside the box (typed, shared, kept from a session that has moved
637
- on), so every slide between here and there is asked to let go the way a key
638
- going that way would ask it, and the first one that holds is where one stops.
639
- `?step=done` cannot open a confirmation screen for something nobody sent — and
640
- the address is then rewritten with the area actually shown, so it never says
641
- one is somewhere one is not.
664
+ That is the whole wiring, and every half of it is the piece that already
665
+ existed. Worth naming, because each answers a question a wizard actually has:
666
+
667
+ - **where it opens is the state's own default** — the first argument of
668
+ `stateSignal`. The container has a `defaultCurrent` for when it owns its
669
+ position, but a bound container does not own it: one place says where the step
670
+ starts, and it is the same place a reset goes back to;
671
+ - **the param is absent while the step IS that default** (route.js prunes it), so
672
+ `/alerts/W-123/edit` is clean on the first step, gains `?step=where` on the
673
+ second, and loses it again coming back. The address carries what differs from
674
+ the usual answer and nothing else;
675
+ - **the container walks to the step rather than jumping to it.** The address comes
676
+ from outside the box (typed, shared, kept from a session that has moved on), so
677
+ every slide between here and there is asked to let go the way a key going that
678
+ way would ask it, and the first one that holds is where one stops. `?step=done`
679
+ cannot open a confirmation screen for something nobody sent. The signal is then
680
+ written with the area actually shown, so the address never says one is
681
+ somewhere one is not;
682
+ - **only the travels that HAPPENED are written.** A travel a lock refused, or one
683
+ `onCurrentChange` refused late, never reaches the signal — or is written back
684
+ when it does.
642
685
 
643
686
  A container remembers nothing across a reload, so a step whose `required` the app
644
687
  knows is already satisfied says so itself (`required={!alreadyFilled}`); the same
645
688
  holds for a hold that a finished job lifts (`preventNavNext={!published}`).
646
689
 
647
- `history: "push"` is the other half, for slides that ARE places one came from — a
648
- gallery one browses:
690
+ **A start that depends on the page one is on** — a wizard creating something
691
+ opens on its first question, the same wizard editing something opens on its
692
+ summary — is said by the ROUTE, not by the state and not by the container:
649
693
 
650
- ```jsx
651
- <SlideContainer urlParam={{ name: "photo", history: "push" }}>
694
+ ```js
695
+ const stepSignal = stateSignal("when", { id: "step", weak: true });
696
+ route("/alerts/create", { searchParams: { step: stepSignal } });
697
+ route("/alerts/:alertId/edit", {
698
+ searchParams: { step: { signal: stepSignal, default: "recap" } },
699
+ });
652
700
  ```
653
701
 
654
- Even there, a slide reached by DRAGGING replaces rather than pushes: swiping back
655
- and forth with a thumb is browsing, not a trail one wants to walk home along.
702
+ Everything that asks "is this the default" then asks the route one is on: both
703
+ addresses stay clean on the step they open on — `/alerts/create` on "when",
704
+ `/alerts/W-123/edit` on "recap" — an address naming no step puts the state back
705
+ on that page's own, and going from one to the other moves the step with it.
656
706
 
657
- What `urlParam` is not: a route. Nothing is declared, nothing matches, no page
658
- transition plays — what travels is the box, and the address is a label on where
659
- the box stands. A position several parts of the app must react to is still a
660
- route.
707
+ Not a `computed` over the route's `matchingSignal`: a state and the route that
708
+ declares it cannot name each other — whichever is written first, the other is
709
+ not initialised yet, and a dynamic default is read while `stateSignal` is being
710
+ built. The route is the right place regardless; it is the thing that knows which
711
+ page one is on.
712
+
713
+ Two containers on one screen are two signals, and that is the whole answer to
714
+ "which one owns the param": the one holding the route's signal. A gallery of
715
+ seven wizards side by side hands each of them a `useSignal` of its own, and
716
+ nothing goes into the address.
717
+
718
+ ### A state whose values ARE places: `history: "push"`
719
+
720
+ Replacement is the default because most URL-held state qualifies the screen one
721
+ is on. A state whose values are places one came from — the photo being looked at
722
+ in a gallery — says so where the state is declared:
723
+
724
+ ```js
725
+ const photoSignal = stateSignal(undefined, { id: "photo", history: "push" });
726
+ ```
727
+
728
+ Every write of it then stacks an entry, wherever the write comes from. Except
729
+ where the writer knows this particular move is not one: a slide reached by
730
+ DRAGGING replaces even in a container that pushes, because swiping back and forth
731
+ with a thumb is browsing, not a trail one wants to walk home along. That is said
732
+ at the write rather than declared:
733
+
734
+ ```js
735
+ photoSignal.set(nextPhoto, { history: "replace" });
736
+ ```
737
+
738
+ `SlideContainer` already does exactly that for its own drags, so a gallery gets
739
+ it by declaring the state and nothing else.
740
+
741
+ ### What this is not
742
+
743
+ It is not a route. Nothing is declared for the steps themselves, nothing matches
744
+ on them, and no page transition plays — a transition needs the page to change
745
+ ([route_transitions.md](./route_transitions.md)), and a search param moving is
746
+ not one. What travels is the box, and the address is a label on where the box
747
+ stands. A position several parts of the app must react to is still a route.
661
748
 
662
749
  Demo: [../src/layout/demos/8_slide_container_demo.html](../src/layout/demos/8_slide_container_demo.html).