@stapel/search-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +203 -0
  3. package/README.md +175 -0
  4. package/dist/api/generated/schema.d.ts +375 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/searchApi.d.ts +77 -0
  9. package/dist/api/searchApi.d.ts.map +1 -0
  10. package/dist/api/searchApi.js +79 -0
  11. package/dist/api/searchApi.js.map +1 -0
  12. package/dist/api/types.d.ts +131 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +20 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/DegradationNotice.d.ts +19 -0
  17. package/dist/default/DegradationNotice.d.ts.map +1 -0
  18. package/dist/default/DegradationNotice.js +14 -0
  19. package/dist/default/DegradationNotice.js.map +1 -0
  20. package/dist/default/ErrorAlert.d.ts +9 -0
  21. package/dist/default/ErrorAlert.d.ts.map +1 -0
  22. package/dist/default/ErrorAlert.js +27 -0
  23. package/dist/default/ErrorAlert.js.map +1 -0
  24. package/dist/default/FacetPanelPane.d.ts +26 -0
  25. package/dist/default/FacetPanelPane.d.ts.map +1 -0
  26. package/dist/default/FacetPanelPane.js +32 -0
  27. package/dist/default/FacetPanelPane.js.map +1 -0
  28. package/dist/default/RankingDisclosurePane.d.ts +17 -0
  29. package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
  30. package/dist/default/RankingDisclosurePane.js +23 -0
  31. package/dist/default/RankingDisclosurePane.js.map +1 -0
  32. package/dist/default/SearchPage.d.ts +34 -0
  33. package/dist/default/SearchPage.d.ts.map +1 -0
  34. package/dist/default/SearchPage.js +16 -0
  35. package/dist/default/SearchPage.js.map +1 -0
  36. package/dist/default/SearchResultCard.d.ts +34 -0
  37. package/dist/default/SearchResultCard.d.ts.map +1 -0
  38. package/dist/default/SearchResultCard.js +38 -0
  39. package/dist/default/SearchResultCard.js.map +1 -0
  40. package/dist/default/SearchResultsPane.d.ts +32 -0
  41. package/dist/default/SearchResultsPane.d.ts.map +1 -0
  42. package/dist/default/SearchResultsPane.js +46 -0
  43. package/dist/default/SearchResultsPane.js.map +1 -0
  44. package/dist/default/SortSelect.d.ts +22 -0
  45. package/dist/default/SortSelect.d.ts.map +1 -0
  46. package/dist/default/SortSelect.js +39 -0
  47. package/dist/default/SortSelect.js.map +1 -0
  48. package/dist/default/UrlIssueNotice.d.ts +12 -0
  49. package/dist/default/UrlIssueNotice.d.ts.map +1 -0
  50. package/dist/default/UrlIssueNotice.js +13 -0
  51. package/dist/default/UrlIssueNotice.js.map +1 -0
  52. package/dist/default/index.d.ts +40 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +34 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/theme.d.ts +24 -0
  57. package/dist/default/theme.d.ts.map +1 -0
  58. package/dist/default/theme.js +39 -0
  59. package/dist/default/theme.js.map +1 -0
  60. package/dist/default/types.d.ts +12 -0
  61. package/dist/default/types.d.ts.map +1 -0
  62. package/dist/default/types.js +2 -0
  63. package/dist/default/types.js.map +1 -0
  64. package/dist/headless/FacetPanel.d.ts +61 -0
  65. package/dist/headless/FacetPanel.d.ts.map +1 -0
  66. package/dist/headless/FacetPanel.js +57 -0
  67. package/dist/headless/FacetPanel.js.map +1 -0
  68. package/dist/headless/RankingDisclosure.d.ts +39 -0
  69. package/dist/headless/RankingDisclosure.d.ts.map +1 -0
  70. package/dist/headless/RankingDisclosure.js +28 -0
  71. package/dist/headless/RankingDisclosure.js.map +1 -0
  72. package/dist/headless/SearchProvider.d.ts +19 -0
  73. package/dist/headless/SearchProvider.d.ts.map +1 -0
  74. package/dist/headless/SearchProvider.js +15 -0
  75. package/dist/headless/SearchProvider.js.map +1 -0
  76. package/dist/headless/SearchResults.d.ts +68 -0
  77. package/dist/headless/SearchResults.d.ts.map +1 -0
  78. package/dist/headless/SearchResults.js +70 -0
  79. package/dist/headless/SearchResults.js.map +1 -0
  80. package/dist/headless/SearchStateProvider.d.ts +69 -0
  81. package/dist/headless/SearchStateProvider.d.ts.map +1 -0
  82. package/dist/headless/SearchStateProvider.js +71 -0
  83. package/dist/headless/SearchStateProvider.js.map +1 -0
  84. package/dist/i18n/errorsMap.d.ts +27 -0
  85. package/dist/i18n/errorsMap.d.ts.map +1 -0
  86. package/dist/i18n/errorsMap.js +37 -0
  87. package/dist/i18n/errorsMap.js.map +1 -0
  88. package/dist/i18n/es.d.ts +13 -0
  89. package/dist/i18n/es.d.ts.map +1 -0
  90. package/dist/i18n/es.js +85 -0
  91. package/dist/i18n/es.js.map +1 -0
  92. package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
  93. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  94. package/dist/i18n/generated/errors.es.gen.js +63 -0
  95. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  96. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  97. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  98. package/dist/i18n/generated/errors.gen.js +183 -0
  99. package/dist/i18n/generated/errors.gen.js.map +1 -0
  100. package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
  101. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  102. package/dist/i18n/generated/errors.ru.gen.js +63 -0
  103. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  104. package/dist/i18n/keys.d.ts +107 -0
  105. package/dist/i18n/keys.d.ts.map +1 -0
  106. package/dist/i18n/keys.js +185 -0
  107. package/dist/i18n/keys.js.map +1 -0
  108. package/dist/i18n/ru.d.ts +29 -0
  109. package/dist/i18n/ru.d.ts.map +1 -0
  110. package/dist/i18n/ru.js +101 -0
  111. package/dist/i18n/ru.js.map +1 -0
  112. package/dist/index.d.ts +72 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +66 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/model/context.d.ts +11 -0
  117. package/dist/model/context.d.ts.map +1 -0
  118. package/dist/model/context.js +16 -0
  119. package/dist/model/context.js.map +1 -0
  120. package/dist/model/queries.d.ts +48 -0
  121. package/dist/model/queries.d.ts.map +1 -0
  122. package/dist/model/queries.js +65 -0
  123. package/dist/model/queries.js.map +1 -0
  124. package/dist/model/queryKeys.d.ts +25 -0
  125. package/dist/model/queryKeys.d.ts.map +1 -0
  126. package/dist/model/queryKeys.js +22 -0
  127. package/dist/model/queryKeys.js.map +1 -0
  128. package/dist/model/runtime.d.ts +25 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +6 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/nav/manifest.d.ts +21 -0
  133. package/dist/nav/manifest.d.ts.map +1 -0
  134. package/dist/nav/manifest.js +34 -0
  135. package/dist/nav/manifest.js.map +1 -0
  136. package/dist/router/index.d.ts +10 -0
  137. package/dist/router/index.d.ts.map +1 -0
  138. package/dist/router/index.js +34 -0
  139. package/dist/router/index.js.map +1 -0
  140. package/dist/state/degradations.d.ts +30 -0
  141. package/dist/state/degradations.d.ts.map +1 -0
  142. package/dist/state/degradations.js +52 -0
  143. package/dist/state/degradations.js.map +1 -0
  144. package/dist/state/facets.d.ts +68 -0
  145. package/dist/state/facets.d.ts.map +1 -0
  146. package/dist/state/facets.js +171 -0
  147. package/dist/state/facets.js.map +1 -0
  148. package/dist/state/urlState.d.ts +143 -0
  149. package/dist/state/urlState.d.ts.map +1 -0
  150. package/dist/state/urlState.js +319 -0
  151. package/dist/state/urlState.js.map +1 -0
  152. package/llms.txt +87 -0
  153. package/manifest.json +652 -0
  154. package/nav-manifest.json +44 -0
  155. package/package.json +133 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/generated/schema.ts +377 -0
  158. package/src/api/searchApi.ts +169 -0
  159. package/src/api/types.ts +160 -0
  160. package/src/default/DegradationNotice.tsx +50 -0
  161. package/src/default/ErrorAlert.tsx +46 -0
  162. package/src/default/FacetPanelPane.tsx +174 -0
  163. package/src/default/RankingDisclosurePane.tsx +116 -0
  164. package/src/default/SearchPage.tsx +87 -0
  165. package/src/default/SearchResultCard.tsx +96 -0
  166. package/src/default/SearchResultsPane.tsx +173 -0
  167. package/src/default/SortSelect.tsx +77 -0
  168. package/src/default/UrlIssueNotice.tsx +37 -0
  169. package/src/default/index.ts +48 -0
  170. package/src/default/theme.tsx +66 -0
  171. package/src/default/types.ts +12 -0
  172. package/src/headless/FacetPanel.tsx +113 -0
  173. package/src/headless/RankingDisclosure.tsx +58 -0
  174. package/src/headless/SearchProvider.tsx +20 -0
  175. package/src/headless/SearchResults.tsx +149 -0
  176. package/src/headless/SearchStateProvider.tsx +175 -0
  177. package/src/i18n/errorsMap.ts +50 -0
  178. package/src/i18n/es.ts +110 -0
  179. package/src/i18n/generated/errors.es.gen.ts +69 -0
  180. package/src/i18n/generated/errors.gen.ts +214 -0
  181. package/src/i18n/generated/errors.json +423 -0
  182. package/src/i18n/generated/errors.ru.gen.ts +69 -0
  183. package/src/i18n/keys.ts +220 -0
  184. package/src/i18n/ru.ts +126 -0
  185. package/src/index.ts +154 -0
  186. package/src/model/context.tsx +28 -0
  187. package/src/model/queries.ts +79 -0
  188. package/src/model/queryKeys.ts +48 -0
  189. package/src/model/runtime.ts +33 -0
  190. package/src/nav/manifest.ts +53 -0
  191. package/src/router/index.ts +38 -0
  192. package/src/state/degradations.ts +75 -0
  193. package/src/state/facets.ts +219 -0
  194. package/src/state/urlState.ts +458 -0
  195. package/tsconfig.json +26 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # @stapel/search-react
package/MODULE.md ADDED
@@ -0,0 +1,203 @@
1
+ # @stapel/search-react — module guide
2
+
3
+ The human companion to the generated `llms.txt` (agent context) and
4
+ `manifest.json` (machine catalog). `README.md` is the how-to; this is the
5
+ why-it-is-shaped-this-way.
6
+
7
+ Built against **stapel-search 0.1.0** (contract pin
8
+ `352cce979cee11936a8c822212fe87e377dd08cf`).
9
+
10
+ ## Layers
11
+
12
+ - **api/** — `createSearchApi(client)`, one method per endpoint a storefront may
13
+ call (`query`, `suggest`, `ranking`), over the generated
14
+ `components["schemas"]` from this pair's own package-local
15
+ `api/generated/schema.ts` (`pnpm gen:api` ← stapel-search `docs/schema.json`).
16
+ `searchQueryParams(state)` is exported from here because it is *also* what the
17
+ query key is built from — see "the key is the request" below.
18
+ - **state/** — the pure layer, and the biggest one: the URL codec
19
+ (`urlState.ts`), the `degraded[]` parser, and the facet model. No React, no
20
+ router, no fetch, which is what makes the round trip testable in both
21
+ directions without a DOM.
22
+ - **model/** — `searchQueryKeys` (one factory, `["search"]` namespace),
23
+ `createSearchRuntime`, context/hooks, and the two read hooks.
24
+ - **headless/** — `SearchProvider`, `SearchStateProvider`, `SearchResults`,
25
+ `FacetPanel`, `RankingDisclosure`.
26
+ - **i18n/** — the en floor + generated backend error bundles; `ru`/`es` as their
27
+ own subpaths. `ru` is the storefront's default language (storefront spec verdict F1).
28
+ - **default/** — the antd skin, a separate entry point.
29
+ - **router/** — the react-router binding, a third entry point (see below).
30
+ - **nav/** — two public entries: `/s` and `/ranking-disclosure`.
31
+
32
+ There is **no `flows/` layer**: stapel-search annotates no `@flow_step`, and a
33
+ search is not a multi-step flow (spec §8.1 — `flows/` only where one exists).
34
+
35
+ ## Why the URL codec is the centre of the package
36
+
37
+ A search that cannot be shared is not a search — the acceptance criteria the
38
+ spec sets (§4.2) are all about a link: copy it into another tab and get the same
39
+ page, press Back and lose exactly the last filter, reload and lose nothing.
40
+
41
+ Every one of those follows from a single decision: **the query string is the
42
+ only copy of the state.** No component holds a duplicate, so there is nothing to
43
+ keep in sync. `SearchStateProvider` parses on every render from the adapter's
44
+ `URLSearchParams` and writes back through `writeSearchState`; the components
45
+ below it read a bag and call mutators.
46
+
47
+ Two consequences worth stating because they are easy to get wrong:
48
+
49
+ 1. **The parameter names are the backend's**, not a prettier set of our own. A
50
+ browser URL is then the API query string — a support ticket that pastes a
51
+ link is a request anyone can replay with curl — and there is no translation
52
+ table to drift.
53
+ 2. **Push vs replace is deliberate.** A filter change PUSHES (that is what makes
54
+ Back remove exactly one filter); typing in the search box and changing the
55
+ page size REPLACE (one history entry per keystroke would make Back useless).
56
+ The adapter forwards the flag; an adapter that ignores it breaks the Back
57
+ button, which is why the seam has it rather than hiding it.
58
+
59
+ ### The cursor is dropped by every change that is not a page move
60
+
61
+ `anchor` is `{v, k, o}` base64url-encoded — a position inside ONE ordered
62
+ candidate set, over the active sort. Carried across a filter or sort change the
63
+ server either refuses it (`error.400.search_bad_cursor`) or honours it against a
64
+ different set and answers page 4 of something nobody asked for.
65
+
66
+ So `patchSearchState` drops `anchor`/`direction` unless the patch itself sets
67
+ them, and every mutator in the provider goes through it. "Changing a filter
68
+ returns you to the first page" is a property of the state machine, not a rule
69
+ each call site remembers.
70
+
71
+ ## Why `@stapel/core` gained repeatable query parameters
72
+
73
+ `f.<slug>` is read with `getlist` server-side: repeating the key is the OR
74
+ within a slug (`stapel-search/query.py`). Core's client built its query string
75
+ with `URLSearchParams.set`, which cannot express that — a second value silently
76
+ replaced the first, and the only alternative was for this pair to hand-build its
77
+ own URL. That would have been a second query encoder sitting next to core's,
78
+ outside its escaping and outside `stapel/no-string-paths`.
79
+
80
+ Core 0.15.0 therefore accepts an ARRAY value and `append`s it, in order; an
81
+ empty array contributes nothing, exactly like `undefined` ("no filter" and "a
82
+ filter with no values" must not produce different URLs). This pair's peer floor
83
+ is `>=0.15.0` for that reason and no other — the symbol set is unchanged, so
84
+ `check:peer-floors` could not have caught an understated floor here.
85
+
86
+ ## The key is the request
87
+
88
+ `searchQueryKeys.query()` is keyed on the very object `searchQueryParams()`
89
+ hands the client. A key built from a hand-picked subset is how a parameter comes
90
+ to change the request but not the key — new filter, cached rows. TanStack hashes
91
+ with sorted-key JSON, so equal states hash equal regardless of construction
92
+ order, and each cursor's page stays cached under its own key, which is what
93
+ makes Back instant.
94
+
95
+ ## `keepPreviousData`, and why it is a correctness choice here
96
+
97
+ Facets are drill-down: the point of the panel is that choosing a value leaves
98
+ its siblings showing what you would get by switching to them. A panel that
99
+ blanks to a spinner between clicks cannot show that — the numbers vanish and
100
+ reappear, which reads exactly like the naive facets this contract avoids. So the
101
+ previous answer stays on screen while the next is in flight, with `isFetching`
102
+ for a skin that wants to dim it.
103
+
104
+ The load discipline is intact: the first load has nothing to keep and is
105
+ `loading`, and a failure still lands as `failed` rather than leaving stale rows
106
+ pretending to be current.
107
+
108
+ ## The two slot seams
109
+
110
+ L2 pairs do not import each other, so the two things a storefront must supply
111
+ are props, not dependencies (spec §6.2 items 1–2):
112
+
113
+ - **`renderCard`** — `<ListingCard>` from `@stapel/listings-react/default`. The
114
+ generic card here is a working default over a documented set of conventional
115
+ `card` fields (`title`, `price`, `currency`, `location`, `image_url`), because
116
+ `SearchItem.card` is deliberately free-form: "stored row fields, so a result
117
+ page costs one query".
118
+ - **`categoryFeatures`** — the category's feature schema, from
119
+ `categories-react`. The server sends `{value: count}` and no labels; the
120
+ captions are translation keys in that schema.
121
+
122
+ Facet values are rendered through `@stapel/attributes-react`'s
123
+ `formatFeatureValue` — the same formatter a card and a spec table use, so a
124
+ value cannot read one way in the filter and another way in the result. The wire
125
+ carries facet values as index TERMS (strings), so `facetOptionLabel` coerces
126
+ back into the shape each type's formatter expects: `select` is a list even for
127
+ one value, `date` is a Unix timestamp integer, `int`/`float` are numbers.
128
+
129
+ ## Honesty flags are not decoration
130
+
131
+ Four independent signals, all surfaced:
132
+
133
+ | Signal | Where it comes from | What must not happen |
134
+ |---|---|---|
135
+ | `exact_total` | envelope | rendering an estimate as an exact count |
136
+ | `facet_meta.approximate` | candidate set over `FACET_CANDIDATE_CAP` | a precise-looking number |
137
+ | `facet_meta.skipped` | plan slugs dropped at `MAX_FACET_FIELDS` | a `0` where nothing was counted |
138
+ | `degraded[]` | `_degradations` + backend + facet counter | silence |
139
+
140
+ `degraded[]` is de-duplicated on parse, because the backend concatenates three
141
+ contributions without de-duplicating them (`services.py`) and the same literal
142
+ can arrive twice. An unrecognised literal is kept with `kind: "unknown"` and
143
+ shown with its raw text — a build that predates a new limitation should say so.
144
+
145
+ ## Where the refusals go
146
+
147
+ `error.400.search_window_exceeded` gets its own branch in the results pane.
148
+ Everything about it looks like an empty page — a 400, no rows — and it means the
149
+ opposite: there are more results, they are just past `MAX_RESULT_WINDOW`. The
150
+ sentence is "narrow the search".
151
+
152
+ The views turn EVERY backend exception into `503
153
+ error.503.search_backend_unavailable`, so a client never has to distinguish a
154
+ 500 from an outage: a dead engine is always "we could not ask", never "there is
155
+ nothing here".
156
+
157
+ ## Notes on the contract, recorded rather than worked around
158
+
159
+ 1. **`docs/flows.json` is `{}`.** Every other module ships `[]`. The monorepo's
160
+ `gen-flows.mjs` requires an array and throws on an object, so this pair is
161
+ not wired into `gen:flows` at all — which costs nothing, because there are no
162
+ flows to generate. Upstream ask: emit `[]` for uniformity.
163
+ 2. **The schema declares no `enum`s.** `sort`, `direction` and `facets` have
164
+ their vocabularies in prose and in Python (`SORTS` is a setting a deployment
165
+ may extend). So `sort` is typed as `string`, `SEARCH_SORTS` is the shipped
166
+ list a control offers, and an unknown value is passed through for the server
167
+ to refuse by name — a client that reset it would rewrite a shared link's
168
+ meaning on load.
169
+ 3. **`info.title` is empty and `info.version` is `0.0.0`** in the schema. The
170
+ manifest's `backend.contract` comes from `pyproject.toml`, as everywhere else
171
+ in this repo; nothing reads the schema's version.
172
+ 4. **`prev_anchor` is absent when the previous page is the first one.** The
173
+ cursor's absence is what "page 1" means under keyset paging, so walking back
174
+ clears `anchor` rather than inventing one.
175
+ 5. **`minLon > maxLon` in a bbox is legal** — the box crosses the antimeridian.
176
+ The codec does not normalize it.
177
+
178
+ ## Tests
179
+
180
+ 103, in 12 files. The ones that exist for a named reason:
181
+
182
+ - `urlState.test.ts` — the round trip in both directions, the repeated `f.`
183
+ key, the antimeridian bbox, unknown parameters preserved, and the cursor
184
+ dropped by every non-paging change.
185
+ - `urlSync.test.tsx` — the same properties through the real provider and skin:
186
+ a facet click lands in the URL and in the next request, the history gains one
187
+ entry per filter (so Back removes one), a shared link reproduces the request.
188
+ - `facets.test.tsx` — drill-down siblings keep their counts, closed sets keep
189
+ their declared order, a skipped slug says "not counted", labels resolve
190
+ through the category schema.
191
+ - `results.test.tsx` — the four load outcomes, the window refusal as its own
192
+ sentence, the estimated-vs-exact count, and `promoted` under every one of the
193
+ five sorts.
194
+ - `pagination.test.tsx` — cursor forward and back with `direction`, both
195
+ controls blocked WITH a reason, and the panel not blanking between clicks.
196
+ - `degraded.test.tsx` — every literal parsed, duplicates collapsed, unknown
197
+ kept.
198
+ - `contract.test.ts` — every operation lands on a path the backend declares,
199
+ every query parameter is one it declares, and `f.<slug>` is repeated rather
200
+ than comma-joined.
201
+ - `pair.test.ts` — nav ids unique and namespaced, `surface` explicit, every
202
+ component the manifest names actually exported, every `labelKey` in the
203
+ bundle.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # @stapel/search-react
2
+
3
+ The frontend pair for **stapel-search**: a storefront's catalogue, category and
4
+ search pages, all three from one endpoint — with drill-down facets, geo, sorts,
5
+ keyset paging, the DSA Art. 26 `promoted` marking and the P2B Art. 5 ranking
6
+ disclosure.
7
+
8
+ Business + state in the main entry, zero visual opinion; the antd skin lives
9
+ behind `./default` and the react-router binding behind `./router`, so a host
10
+ that renders its own visuals carries neither. Built on `@stapel/core` (typed
11
+ client + `StapelApiError` envelope, `LoadState`, i18n engine, analytics seam,
12
+ TanStack Query) and `@stapel/attributes-react` (facet value display).
13
+
14
+ ## Install
15
+
16
+ ```
17
+ pnpm add @stapel/search-react @stapel/core @stapel/attributes-react @tanstack/react-query react
18
+ # for the default skin:
19
+ pnpm add antd @stapel/tokens-antd
20
+ # for the react-router URL binding:
21
+ pnpm add react-router
22
+ ```
23
+
24
+ ## A search page, in eight lines
25
+
26
+ **No session, no workspace id, no auth client** — every endpoint this pair calls
27
+ is `AllowAny`, so a catalogue renders for a visitor who will never sign in:
28
+
29
+ ```tsx
30
+ import { createSearchRuntime, SearchProvider } from "@stapel/search-react";
31
+ import { SearchPage } from "@stapel/search-react/default";
32
+ import { useRouterSearchParams } from "@stapel/search-react/router";
33
+
34
+ const runtime = createSearchRuntime({ baseUrl: "/search/api/v1/" });
35
+
36
+ export function SearchRoute() {
37
+ return (
38
+ <SearchProvider runtime={runtime}>
39
+ <SearchPage adapter={useRouterSearchParams()} defaultType="listing" />
40
+ </SearchProvider>
41
+ );
42
+ }
43
+ ```
44
+
45
+ In an app that already has a session, put the search client on core's provider
46
+ alongside the others and keep one client per module:
47
+
48
+ ```tsx
49
+ <StapelProvider client={authRuntime.client} clients={{ search: runtime.client }} i18n={i18n}>
50
+ <SearchProvider runtime={runtime}>{app}</SearchProvider>
51
+ </StapelProvider>
52
+ ```
53
+
54
+ ## The URL is the state
55
+
56
+ Text, category, facet filters, ranges, geo, sort, page size and the keyset
57
+ cursor all live in the query string — under the **backend's own parameter
58
+ names**, so a browser URL *is* the API query string and a pasted link is a
59
+ request anyone can replay with curl:
60
+
61
+ ```
62
+ /s?type=listing&q=drill&f.brand=bosch&f.brand=makita&r.price=100..500
63
+ &lat=55.75&lon=37.62&radius_km=25&sort=price_asc&anchor=…
64
+ ```
65
+
66
+ No component keeps a second copy, which is what makes all three of these
67
+ properties hold by construction rather than by discipline:
68
+
69
+ | You do this | This happens |
70
+ |---|---|
71
+ | copy the address into another tab | the same results |
72
+ | press Back | exactly the last filter comes off |
73
+ | reload | nothing is lost |
74
+ | change a filter while on page 4 | you land on page 1 — the cursor is dropped |
75
+
76
+ That last row is not a nicety. `anchor` encodes a position inside **one**
77
+ ordered candidate set; carried across a filter change it either gets refused or,
78
+ worse, honoured against a different set. `patchSearchState` drops it for every
79
+ change that is not itself a page move, so no call site has to remember.
80
+
81
+ **Any router, or none.** `SearchStateProvider` takes a `SearchParamsAdapter` —
82
+ two members, shaped exactly like react-router's `useSearchParams()`. `./router`
83
+ ships that binding; a Next.js app, a hash router or a plain `URLSearchParams` in
84
+ a test satisfy the same seam.
85
+
86
+ ## Facets are drill-down, and the panel says so
87
+
88
+ Each facet is counted over the candidates **with its own filter removed**
89
+ (`stapel-search/facets.py`). So picking *Bosch* does not zero *Makita*: its
90
+ count stays what you would get by switching to it. Closed option sets arrive
91
+ exhaustively, zeros included, and keep their authored order.
92
+
93
+ The server does not send option **labels** — they are translation keys in the
94
+ category's feature schema. Hand that schema in and the options read as words:
95
+
96
+ ```tsx
97
+ <FacetPanelPane categoryFeatures={features} /> // from categories-react
98
+ ```
99
+
100
+ Without it, options read as raw index terms. Never as blanks.
101
+
102
+ ## What the server admits, the screen repeats
103
+
104
+ | The envelope says | The page says |
105
+ |---|---|
106
+ | `exact_total: false` | "About 1 200 results", never "1 200" |
107
+ | `facet_meta.approximate` | "counts are approximate — too many candidates to count them all" |
108
+ | `facet_meta.skipped: [...]` | those slugs are named, and their options show **"not counted"** — never `0` |
109
+ | `degraded: ["category_rollup", …]` | a banner, one line per degradation, the scorer named |
110
+ | a `degraded` literal this build predates | the generic sentence **plus the raw literal** |
111
+
112
+ A silent `0` where a facet was not counted is the same defect class as
113
+ `data ?? []`: a number that looks like an answer and is not one.
114
+
115
+ ## A failed search is never "nothing found"
116
+
117
+ A result read has **four** outcomes and the skin says a different thing for
118
+ each — `matchList`'s arms are all required, so forgetting one does not compile:
119
+
120
+ | What happened | What the person sees |
121
+ |---|---|
122
+ | in flight | a skeleton |
123
+ | 200, no hits | "nothing matches this search" |
124
+ | 5xx / network | "we could not run this search" + retry |
125
+ | 400 `search_window_exceeded` | "narrow the search instead of paging further" |
126
+
127
+ The last row matters on its own: the window refusal arrives as a 400 with no
128
+ rows, which is exactly the shape that renders as an empty page if nobody
129
+ branches on it.
130
+
131
+ ## `promoted` is not optional
132
+
133
+ DSA Art. 26 marking rides **every** item under **every** sort. The card slot
134
+ receives the whole item, and the default card renders the tag:
135
+
136
+ ```tsx
137
+ <SearchResultsPane renderCard={(item) => <ListingCard item={item} />} />
138
+ ```
139
+
140
+ A storefront replaces the generic card with its own (`<ListingCard>` from
141
+ `@stapel/listings-react/default`); what it may not do is drop the marking. The
142
+ companion half — *why these results are in this order* — is the P2B Art. 5
143
+ disclosure, generated from the backend's scorer registry and shipped as both a
144
+ headless bag and a page (`<RankingDisclosurePane>`), including the parameters
145
+ the configured engine cannot evaluate.
146
+
147
+ ## Surface
148
+
149
+ | Layer | Exports |
150
+ |---|---|
151
+ | api | `createSearchApi`, `searchQueryParams`, `SEARCH_SORTS`, wire types |
152
+ | state (pure) | `parseSearchState`, `writeSearchState`, `patchSearchState`, `toggleFilterValue`, `setFilterValues`, `setRangeValue`, `clearFilters`, `activeFilterCount`, `parseDegradations`, `countIsEstimate`, `buildFacetGroups`, `facetOptionLabel` |
153
+ | model | `createSearchRuntime`, `searchQueryKeys`, `useSearchQuery`, `useRankingDisclosure` |
154
+ | headless | `SearchProvider`, `SearchStateProvider`/`useSearchState`, `SearchResults`, `FacetPanel`, `RankingDisclosure` |
155
+ | `./default` | `SearchPage`, `SearchResultsPane`, `FacetPanelPane`, `RankingDisclosurePane`, `SearchResultCard`, `DegradationNotice`, `UrlIssueNotice`, `SortSelect`, `SearchSkinTheme` |
156
+ | `./router` | `useRouterSearchParams` |
157
+ | i18n | `registerSearchI18n` (+ `./i18n/ru`, `./i18n/es`) |
158
+ | errors | `SEARCH_ERRORS`, `explainSearchError`, `SEARCH_WINDOW_EXCEEDED`, `SEARCH_BACKEND_UNAVAILABLE` |
159
+
160
+ ## Not in this version
161
+
162
+ - **`suggest`** — the endpoint is typed and callable (`api.suggest(...)`), but
163
+ there is no hook and no autocomplete widget: debounce and keyboard handling
164
+ are their own piece of work.
165
+ - **`health` / `reindex`** — index-operator endpoints (`IsNotAnonymousUser` +
166
+ `can_manage`). Not a storefront's surface, so not on this pair's client.
167
+ - **a map** — geo is complete on the wire (`lat`/`lon`/`radius_km`, `bbox`,
168
+ `sort=distance`) and the state layer carries all of it; drawing a map is a
169
+ tile provider, a key and a CSP change.
170
+ - **saved searches.**
171
+
172
+ ## More
173
+
174
+ `MODULE.md` — why it is shaped this way. `llms.txt` — the agent-facing
175
+ description, generated and drift-gated.