@stapel/search-react 0.38.0 → 0.40.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 (41) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +85 -6
  3. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  4. package/dist/default/FacetGroupControl.js +31 -4
  5. package/dist/default/FacetGroupControl.js.map +1 -1
  6. package/dist/default/FacetPanelPane.d.ts +24 -0
  7. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  8. package/dist/default/FacetPanelPane.js +27 -6
  9. package/dist/default/FacetPanelPane.js.map +1 -1
  10. package/dist/default/PartitionChips.d.ts +55 -0
  11. package/dist/default/PartitionChips.d.ts.map +1 -1
  12. package/dist/default/PartitionChips.js +59 -4
  13. package/dist/default/PartitionChips.js.map +1 -1
  14. package/dist/default/SearchPage.d.ts +33 -1
  15. package/dist/default/SearchPage.d.ts.map +1 -1
  16. package/dist/default/SearchPage.js +32 -5
  17. package/dist/default/SearchPage.js.map +1 -1
  18. package/dist/default/SearchResultsPane.d.ts +76 -0
  19. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  20. package/dist/default/SearchResultsPane.js +132 -45
  21. package/dist/default/SearchResultsPane.js.map +1 -1
  22. package/dist/default/index.d.ts +5 -3
  23. package/dist/default/index.d.ts.map +1 -1
  24. package/dist/default/index.js +2 -1
  25. package/dist/default/index.js.map +1 -1
  26. package/dist/default/swatches.d.ts +44 -0
  27. package/dist/default/swatches.d.ts.map +1 -0
  28. package/dist/default/swatches.js +200 -0
  29. package/dist/default/swatches.js.map +1 -0
  30. package/llms.txt +3 -3
  31. package/manifest.json +4 -1
  32. package/nav-manifest.json +1 -1
  33. package/package.json +4 -4
  34. package/src/analytics/generated/events.json +1 -1
  35. package/src/default/FacetGroupControl.tsx +39 -0
  36. package/src/default/FacetPanelPane.tsx +61 -5
  37. package/src/default/PartitionChips.tsx +164 -4
  38. package/src/default/SearchPage.tsx +72 -1
  39. package/src/default/SearchResultsPane.tsx +134 -5
  40. package/src/default/index.ts +14 -1
  41. package/src/default/swatches.ts +214 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * A COLOUR FACET SHOWS THE COLOUR.
3
+ *
4
+ * The reference's colour group draws a filled dot beside every value; ours
5
+ * drew the word and made the buyer read it (deep/elektronika-telefony.md §3,
6
+ * the one rail regression that pass found). A colour is the one attribute
7
+ * whose label is strictly worse than the thing itself — "silver" versus
8
+ * "gold" in a catalogue's own transliteration is a paragraph of prose for a difference a 10px dot settles.
9
+ *
10
+ * Two questions, and each is answered conservatively, because both failures
11
+ * are visible on a shopper's screen:
12
+ *
13
+ * 1. **is this axis a colour?** — {@link isColorAxis}, from the slug the
14
+ * catalogue mapped the axis to (and the address key, and `axis_role` if a
15
+ * schema ever carries one). A guess on the slug, exactly like
16
+ * `looksLikeExclusiveAxisSlug` next door: nothing on the wire marks an
17
+ * axis "colour", and inspecting the VALUES for colour-ish names would put
18
+ * dots on a paint-brand axis whose makes are called `Bordeaux`;
19
+ * 2. **which colour is this value?** — {@link swatchColor}, and the honest
20
+ * answer is usually "nobody said". A value code is a catalogue's own term
21
+ * (`chernyy`, `dark-slate-2`) and neither the answer, the feature schema
22
+ * nor the vocabulary endpoint carries a hue for it. So the pair paints a
23
+ * dot only where the code IS a colour by a name it can resolve — the
24
+ * design system's own colour roles first (§68: one neutral vocabulary of
25
+ * ROLES, and it deliberately ships no hue ramp), then CSS's own colour
26
+ * keywords, then a code that spells the hue out in hex — and draws
27
+ * NOTHING otherwise. An invented mapping from a transliterated Russian
28
+ * word to a hex value is data this pair does not have.
29
+ */
30
+ import { cssVar } from "@stapel/tokens";
31
+ /**
32
+ * The control-type tails a catalogue hangs on an axis slug when one feature
33
+ * type is not enough to tell two mappings apart (`color_ref_select` is the
34
+ * live phones leaf's own colour axis; `color_select` and `color_multi` are
35
+ * the same axis under a different editor).
36
+ *
37
+ * Stripped before the head is read, so the control a value is PICKED with
38
+ * cannot change what the value IS.
39
+ */
40
+ const CONTROL_TAILS = [
41
+ "ref_hierarchical_select",
42
+ "hierarchical_select",
43
+ "ref_select",
44
+ "multiselect",
45
+ "multi_select",
46
+ "select",
47
+ "multi",
48
+ "picker",
49
+ "field",
50
+ ];
51
+ function normalizeSlug(slug) {
52
+ let normalized = slug.toLowerCase().replace(/-/g, "_");
53
+ for (const tail of CONTROL_TAILS) {
54
+ if (normalized.endsWith(`_${tail}`)) {
55
+ normalized = normalized.slice(0, -(tail.length + 1));
56
+ break;
57
+ }
58
+ }
59
+ return normalized;
60
+ }
61
+ /** The two spellings, and only as the slug's HEAD segment: `color_fridge` is
62
+ * the colour of a fridge and `colorado_region` is a place. */
63
+ const COLOR_HEADS = new Set(["color", "colour"]);
64
+ function headIsColor(slug) {
65
+ if (slug === undefined || slug === "")
66
+ return false;
67
+ const normalized = normalizeSlug(slug);
68
+ const head = normalized.split("_")[0] ?? "";
69
+ return COLOR_HEADS.has(head);
70
+ }
71
+ /**
72
+ * Is this axis a colour vocabulary?
73
+ *
74
+ * Three sources, in the order of how much authority they carry:
75
+ *
76
+ * - `axis_role` — the schema SAYING what an axis is, which is the only
77
+ * non-guess available. The canon's role vocabulary is closed and has no
78
+ * colour in it yet (`make`/`model`/`generation`/`year`/`mileage`), so this
79
+ * arm reads the field as text and is dead until the canon grows one. It is
80
+ * written now so that the day it does, nothing here has to change;
81
+ * - the axis slug, and the ADDRESS key beside it — the live phones leaf maps
82
+ * its colour to `color_ref_select` and publishes it as `color`, so either
83
+ * spelling alone would miss half the deployments.
84
+ */
85
+ export function isColorAxis(group) {
86
+ const role = group.feature?.["axis_role"];
87
+ if (typeof role === "string" && COLOR_HEADS.has(role.toLowerCase()))
88
+ return true;
89
+ return headIsColor(group.slug) || headIsColor(group.urlKey);
90
+ }
91
+ /**
92
+ * The design system's colour ROLES, for a value code that names one.
93
+ *
94
+ * The fleet's token vocabulary is neutral and role-shaped on purpose (§68):
95
+ * there is no `red`, no `blue`, and no ramp — so a colour axis whose values
96
+ * are hues matches nothing here, which is the correct answer rather than a
97
+ * gap. What does match is a catalogue that codes STATES as colours (a
98
+ * `status` axis mapped under a colour slug), and those take the brand's own
99
+ * value in both themes rather than a frozen hex.
100
+ */
101
+ const TOKEN_ROLE_SWATCHES = {
102
+ brand: cssVar("brand"),
103
+ error: cssVar("error"),
104
+ info: cssVar("info"),
105
+ link: cssVar("link"),
106
+ success: cssVar("success"),
107
+ surface: cssVar("surface"),
108
+ text: cssVar("text"),
109
+ };
110
+ /**
111
+ * CSS's own colour keywords — a NAME that matches, in the vocabulary every
112
+ * browser already agrees on.
113
+ *
114
+ * The basic sixteen plus the extended keywords a product catalogue actually
115
+ * uses. Deliberately not the full 148: every entry here is a promise that a
116
+ * value code spelled that way means that colour, and `rebeccapurple` in a
117
+ * phone catalogue is far likelier to be somebody's model name.
118
+ */
119
+ const CSS_COLOR_KEYWORDS = [
120
+ "aqua",
121
+ "beige",
122
+ "black",
123
+ "blue",
124
+ "brown",
125
+ "chocolate",
126
+ "coral",
127
+ "crimson",
128
+ "cyan",
129
+ "fuchsia",
130
+ "gold",
131
+ "gray",
132
+ "green",
133
+ "grey",
134
+ "indigo",
135
+ "ivory",
136
+ "khaki",
137
+ "lavender",
138
+ "lime",
139
+ "magenta",
140
+ "maroon",
141
+ "navy",
142
+ "olive",
143
+ "orange",
144
+ "orchid",
145
+ "pink",
146
+ "plum",
147
+ "purple",
148
+ "red",
149
+ "salmon",
150
+ "sand",
151
+ "sienna",
152
+ "silver",
153
+ "skyblue",
154
+ "tan",
155
+ "teal",
156
+ "tomato",
157
+ "turquoise",
158
+ "violet",
159
+ "wheat",
160
+ "white",
161
+ "yellow",
162
+ ];
163
+ const CSS_COLOR_SET = new Set(CSS_COLOR_KEYWORDS);
164
+ /** `#abc`, `#aabbcc`, `#aabbccdd` — a catalogue that codes the hue itself. */
165
+ const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
166
+ /**
167
+ * The CSS colour one value code names, or `null` for "nobody said".
168
+ *
169
+ * `null` is the ordinary answer and the row then draws no dot at all: a grey
170
+ * placeholder beside eleven values would say "these are all the same colour",
171
+ * which is worse than the word on its own.
172
+ *
173
+ * The code is read as written apart from case and separators — `dark_blue`
174
+ * and `dark-blue` are one code and neither is a CSS keyword, so both get
175
+ * nothing. Only a code that IS a name resolves.
176
+ */
177
+ export function swatchColor(code) {
178
+ const raw = code.trim();
179
+ if (raw === "")
180
+ return null;
181
+ if (HEX.test(raw))
182
+ return raw;
183
+ const normalized = raw.toLowerCase().replace(/[-_\s]/g, "");
184
+ const role = TOKEN_ROLE_SWATCHES[normalized];
185
+ if (role !== undefined)
186
+ return role;
187
+ return CSS_COLOR_SET.has(normalized) ? normalized : null;
188
+ }
189
+ /**
190
+ * The dot a value gets when this axis is a colour AND the value names one.
191
+ * `null` everywhere else, which is most of the time — see {@link swatchColor}.
192
+ */
193
+ export function facetSwatch(group, code) {
194
+ return isColorAxis(group) ? swatchColor(code) : null;
195
+ }
196
+ /** The swatch's own size, in CSS pixels: a dot beside a line of text, sized
197
+ * to the x-height rather than to the control, so it reads as part of the
198
+ * label and not as a second checkbox. */
199
+ export const SWATCH_SIZE = 12;
200
+ //# sourceMappingURL=swatches.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"swatches.js","sourceRoot":"","sources":["../../src/default/swatches.ts"],"names":[],"mappings":"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"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @stapel/search-react 0.38.0
1
+ # @stapel/search-react 0.40.0
2
2
 
3
3
  Headless React flow pair for stapel-search (contract >=0.16 <0.17) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
@@ -85,9 +85,9 @@ const { tracked } = useTracked();
85
85
  - search.language-select → <LanguageSelect> [offered|from-a-link] demo/LanguageSelect.demo.tsx
86
86
  - search.location-summary → <LocationSummaryLine> [everywhere|placed|narrowed|long-place|wide] demo/LocationSummaryLine.demo.tsx
87
87
  - search.other-categories-line → <OtherCategoriesLine> [line|narrow|empty] demo/OtherCategoriesLine.demo.tsx
88
- - search.page → <SearchPage> [desktop|phone|catalogue-leaf|under-a-header|system-scrollbar|legacy-rhythm|filters-header-navigates|unreadable-link] demo/SearchPage.demo.tsx
88
+ - search.page → <SearchPage> [desktop|phone|catalogue-leaf|under-a-header|toolbar-not-pinned|rail-on-a-panel|system-scrollbar|legacy-rhythm|filters-header-navigates|unreadable-link] demo/SearchPage.demo.tsx
89
89
  - search.page-size-select → <PageSizeSelect> [ladder|off-ladder] demo/PageSizeSelect.demo.tsx
90
- - search.partition-chips → <PartitionChips> [parent|counted|child] demo/PartitionChips.demo.tsx
90
+ - search.partition-chips → <PartitionChips> [parent|counted|pointer|child] demo/PartitionChips.demo.tsx
91
91
  - search.popular-values → <PopularValues> [desktop|responsive|narrow-column] demo/PopularValues.demo.tsx
92
92
  - search.range-filter-row → <RangeFilterRow> [untouched|applied] demo/RangeFilterRow.demo.tsx
93
93
  - search.ranking-pane → <RankingDisclosurePane> [desktop|phone] demo/RankingDisclosurePane.demo.tsx
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.38.0",
4
+ "version": "0.40.0",
5
5
  "backend": {
6
6
  "module": "stapel-search",
7
7
  "contract": ">=0.16 <0.17"
@@ -582,6 +582,8 @@
582
582
  "phone",
583
583
  "catalogue-leaf",
584
584
  "under-a-header",
585
+ "toolbar-not-pinned",
586
+ "rail-on-a-panel",
585
587
  "system-scrollbar",
586
588
  "legacy-rhythm",
587
589
  "filters-header-navigates",
@@ -611,6 +613,7 @@
611
613
  "variants": [
612
614
  "parent",
613
615
  "counted",
616
+ "pointer",
614
617
  "child"
615
618
  ],
616
619
  "source": "demo/PartitionChips.demo.tsx"
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "search.results",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,9 +57,9 @@
57
57
  "limit": "14.5 KB"
58
58
  },
59
59
  {
60
- "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes, then 29.25 → 30.5 KB for the panel that draws all of it: groups and ranges as one ordered sequence with one «Apply» for the panel rather than one per row, the empty-group heading rule, `categoryFilter={false}` and the `resultsLead` slot for a page reached by walking the catalogue, `PopularValues columns=\\\"responsive\\\"` on a container query, `SortSelect` annotating a blocked option at every width, a loading arm that covers the whole pane (0.34 CLS on a live host), and the chip row reserving its own box (a further 0.045). Measured with dependencies held constant, this package's src at the commit before that work and then at it: 29.05 -> 30.16 KB — 1.11 KB, and every part of it is a shift or a mislabelled control a host was living with, then 30.5 -> 31 KB for `<SearchResultsPane columns>` and its pass-through on `<SearchPage resultsColumns>`: a fixed track count or a per-breakpoint map, emitted as a hoisted container-query sheet (the results block is the window minus a 280px rail, so a media query would answer about a width the cards never have). Measured with dependencies held constant, this package's src before and after: 30.40 -> 30.65 KB — 250 B, and what it replaces is a host's `!important` rule against a grid declaration it could not read back, then 31 -> 31.25 KB for the segmented partition being antd's `Segmented` rather than a row of buttons wearing a border: the walker read `data-variant=\"segmented\"` with `role=\"radiogroup\"` and found `.ant-segmented` zero times and `input[type=radio]` zero times (D304), so the cells are the design system's now — real radios under one name, the browser's own arrow keys and Tab stop. Measured with dependencies held constant, this package's src before and after: 30.95 -> 31.05 KB — 100 B, against ~30 lines of hand-rolled joined-row geometry deleted, then 31.25 -> 31.5 KB for the two PINNING seams: `<SearchPage railTop>` (the rail's sticky offset and its height cap moved together, so a rail under a 64px header still ends at the foot of the window) and `<SearchResultsPane stickyToolbar>` with the toolbar's own element in both header shapes. Measured with dependencies held constant, this package's src before and after: 31.05 -> 31.2 KB — 150 B, and what it replaces in one deployment is an `!important` over the pair's own inline geometry, a `:has()`, a `display: contents` and this pair's rail breakpoint restated in the host's media query, then 31.5 -> 31.75 KB for the rail saying when its answer is IN FLIGHT: `FacetPanelBag.refreshing` off `loadStateFromQuery(…, { keepPrevious })`, `data-facets-refreshing` on the rail, and each facet group standing on its own last measured height (a `min-block-size` floor held in a ref) until the new answer lands. Measured with dependencies held constant, this package's src before and after: 31.43 -> 31.51 KB — 80 B, against 0.0586 CLS on a partition press (p43), where the groups survive and RESIZE, then 31.75 -> 32 KB for the FIRST MOUNT, which had none of that: `refreshing` is never true on a cold load, so nothing above reached the one pass a plain load is made of. Three parts. `<SearchPage categoryFeaturesPending>` / `<FacetPanelPane categoryFeaturesPending>` is the third state `categoryFeatures` never had — undefined means both \"no schema\" and \"not yet\", and on a category leaf the schema is a SECOND read, so the panel drew the rail from the answer alone and then drew it again when the schema landed: on the live cars leaf make and model went from three-row checkbox lists to one-row dictionary fields, condition and colour from checkboxes to pills, and `orderFacetGroupsBySchema` reordered all of them (p41, 0.0586 CLS on a plain load of `/c/transport-avtomobili`, make -152px and model -76px). Told the schema is coming, the panel keeps the box it already reserves and draws the rail ONCE. Second, every group now stands on a DECLARED box from the frame it mounts in (`facetGroupReservedHeight` — heading, rows and fold per shape), with its measured height preferred only while an answer is in flight. Third, `<SearchPage filtersHeaderReserve>` puts the host's own band above the rail in flow from the first frame, for the partition row that is two chained catalogue reads behind the answer. Measured with dependencies held constant, this package's src before and after: 30.77 -> 31.14 KB — 366 B, then 32 -> 32.25 KB for the filter sheet's open state becoming the HOST's: `<SearchPage filtersOpen>` / `onFiltersOpenChange(open, reason)` as React's usual controlled pair, and `filtersHeader` accepting a function handed `{ closeFilters, open }`. What it costs is the four call sites naming WHY the sheet moved (`open`, `apply`, `dismiss`, `consumer`) and the slot's function arm; what it buys is a header whose own control NAVIGATES being able to take the sheet down on the same press — the page published only `defaultFiltersOpen`, so a partition chip inside the sheet left the drawer standing over the page it had just opened. Measured with dependencies held constant, this package's src before and after: 31.14 -> 31.26 KB — 120 B, then 32.25 -> 32.5 KB for the two levers a storefront could not reach: `<SearchPage footerBar>` (the rail's footer bar writes its own `display` inline, so a consumer stylesheet could only suppress it with an `!important` its own gate forbids, and this page hard-coded the column's value) and `PartitionChild.count`, which the chip draws itself in the muted weight the facet rows use instead of a host welding the number onto the name. Measured with dependencies held constant, this package's src before and after: 32.01 -> 32.10 KB — 90 B, against a string-joining hack and an unreachable bar, then 32.5 -> 32.75 KB for THE THREE BOXES A STOREFRONT WAS HOLDING FOR THIS PAIR. The phone chip row's reserve is `chipRowMinHeight(token.controlHeight)` instead of a `44 + …` constant: the touch floor raises the chips to 44 only BELOW the tablet breakpoint, so from 768px up the reserve stood 12px taller than the 40px row and the results pane ROSE when the row landed (0.0725 CLS on a category leaf at 768, against 0.00016 at 390). `<SortSelect compact>` reserves the width of the longest label it can display, measured by the browser through an `aria-hidden` sizer stacked with the select in one grid cell, instead of `minWidth: 0` inline and a control that GREW 115px when the answer named the sort. And `<SearchResultsPane>` names its own root (`data-testid=\"search-results-pane\"`) and takes `reserve` — `<SearchPage resultsReserve>` — for the box the feed arrives into, which a host was holding with `#search-page > :last-child`. Measured with dependencies held constant, this package's src before and after: 32.10 -> 32.25 KB — 150 B, against a hand-guessed height, a sibling-count selector and two stylesheet rules a consumer can now delete. 32.75 KB HOLDS for the rail's own scrollbar and the block rhythm (owner's walk of the storefront, dark theme): measured 32539 B against the 32.75 KB line, 290 B over the 32249 B this note last recorded, and 211 B of room left. The rail stays its own scroll container — filters that stay put while the results move under them is the whole point of it — and `railScrollbar` names whose BAR draws in the gutter: `\"styled\"` (the new default) is a hoisted rule set in both vendor forms, a 6px track with no arrows and no track fill and a thumb that is transparent at rest and arrives from the tokens on hover or focus-within, standing always under `(pointer: coarse)` where neither fires; `\"system\"` hands the port back to the platform and mounts no sheet at all. The system bar was never a decision, which is why the default is the new arm. The rest is `blockRhythm`: one gap for every block on this page from `var(--stapel-block-gap)` / `var(--stapel-block-gap-compact)` with each block's outer margin reset, in place of the flat `spacing[4]` the root `<Flex>` wrote inline. A budget is not raised for a change that fits under the line it already has",
60
+ "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes, then 29.25 → 30.5 KB for the panel that draws all of it: groups and ranges as one ordered sequence with one «Apply» for the panel rather than one per row, the empty-group heading rule, `categoryFilter={false}` and the `resultsLead` slot for a page reached by walking the catalogue, `PopularValues columns=\\\"responsive\\\"` on a container query, `SortSelect` annotating a blocked option at every width, a loading arm that covers the whole pane (0.34 CLS on a live host), and the chip row reserving its own box (a further 0.045). Measured with dependencies held constant, this package's src at the commit before that work and then at it: 29.05 -> 30.16 KB — 1.11 KB, and every part of it is a shift or a mislabelled control a host was living with, then 30.5 -> 31 KB for `<SearchResultsPane columns>` and its pass-through on `<SearchPage resultsColumns>`: a fixed track count or a per-breakpoint map, emitted as a hoisted container-query sheet (the results block is the window minus a 280px rail, so a media query would answer about a width the cards never have). Measured with dependencies held constant, this package's src before and after: 30.40 -> 30.65 KB — 250 B, and what it replaces is a host's `!important` rule against a grid declaration it could not read back, then 31 -> 31.25 KB for the segmented partition being antd's `Segmented` rather than a row of buttons wearing a border: the walker read `data-variant=\"segmented\"` with `role=\"radiogroup\"` and found `.ant-segmented` zero times and `input[type=radio]` zero times (D304), so the cells are the design system's now — real radios under one name, the browser's own arrow keys and Tab stop. Measured with dependencies held constant, this package's src before and after: 30.95 -> 31.05 KB — 100 B, against ~30 lines of hand-rolled joined-row geometry deleted, then 31.25 -> 31.5 KB for the two PINNING seams: `<SearchPage railTop>` (the rail's sticky offset and its height cap moved together, so a rail under a 64px header still ends at the foot of the window) and `<SearchResultsPane stickyToolbar>` with the toolbar's own element in both header shapes. Measured with dependencies held constant, this package's src before and after: 31.05 -> 31.2 KB — 150 B, and what it replaces in one deployment is an `!important` over the pair's own inline geometry, a `:has()`, a `display: contents` and this pair's rail breakpoint restated in the host's media query, then 31.5 -> 31.75 KB for the rail saying when its answer is IN FLIGHT: `FacetPanelBag.refreshing` off `loadStateFromQuery(…, { keepPrevious })`, `data-facets-refreshing` on the rail, and each facet group standing on its own last measured height (a `min-block-size` floor held in a ref) until the new answer lands. Measured with dependencies held constant, this package's src before and after: 31.43 -> 31.51 KB — 80 B, against 0.0586 CLS on a partition press (p43), where the groups survive and RESIZE, then 31.75 -> 32 KB for the FIRST MOUNT, which had none of that: `refreshing` is never true on a cold load, so nothing above reached the one pass a plain load is made of. Three parts. `<SearchPage categoryFeaturesPending>` / `<FacetPanelPane categoryFeaturesPending>` is the third state `categoryFeatures` never had — undefined means both \"no schema\" and \"not yet\", and on a category leaf the schema is a SECOND read, so the panel drew the rail from the answer alone and then drew it again when the schema landed: on the live cars leaf make and model went from three-row checkbox lists to one-row dictionary fields, condition and colour from checkboxes to pills, and `orderFacetGroupsBySchema` reordered all of them (p41, 0.0586 CLS on a plain load of `/c/transport-avtomobili`, make -152px and model -76px). Told the schema is coming, the panel keeps the box it already reserves and draws the rail ONCE. Second, every group now stands on a DECLARED box from the frame it mounts in (`facetGroupReservedHeight` — heading, rows and fold per shape), with its measured height preferred only while an answer is in flight. Third, `<SearchPage filtersHeaderReserve>` puts the host's own band above the rail in flow from the first frame, for the partition row that is two chained catalogue reads behind the answer. Measured with dependencies held constant, this package's src before and after: 30.77 -> 31.14 KB — 366 B, then 32 -> 32.25 KB for the filter sheet's open state becoming the HOST's: `<SearchPage filtersOpen>` / `onFiltersOpenChange(open, reason)` as React's usual controlled pair, and `filtersHeader` accepting a function handed `{ closeFilters, open }`. What it costs is the four call sites naming WHY the sheet moved (`open`, `apply`, `dismiss`, `consumer`) and the slot's function arm; what it buys is a header whose own control NAVIGATES being able to take the sheet down on the same press — the page published only `defaultFiltersOpen`, so a partition chip inside the sheet left the drawer standing over the page it had just opened. Measured with dependencies held constant, this package's src before and after: 31.14 -> 31.26 KB — 120 B, then 32.25 -> 32.5 KB for the two levers a storefront could not reach: `<SearchPage footerBar>` (the rail's footer bar writes its own `display` inline, so a consumer stylesheet could only suppress it with an `!important` its own gate forbids, and this page hard-coded the column's value) and `PartitionChild.count`, which the chip draws itself in the muted weight the facet rows use instead of a host welding the number onto the name. Measured with dependencies held constant, this package's src before and after: 32.01 -> 32.10 KB — 90 B, against a string-joining hack and an unreachable bar, then 32.5 -> 32.75 KB for THE THREE BOXES A STOREFRONT WAS HOLDING FOR THIS PAIR. The phone chip row's reserve is `chipRowMinHeight(token.controlHeight)` instead of a `44 + …` constant: the touch floor raises the chips to 44 only BELOW the tablet breakpoint, so from 768px up the reserve stood 12px taller than the 40px row and the results pane ROSE when the row landed (0.0725 CLS on a category leaf at 768, against 0.00016 at 390). `<SortSelect compact>` reserves the width of the longest label it can display, measured by the browser through an `aria-hidden` sizer stacked with the select in one grid cell, instead of `minWidth: 0` inline and a control that GREW 115px when the answer named the sort. And `<SearchResultsPane>` names its own root (`data-testid=\"search-results-pane\"`) and takes `reserve` — `<SearchPage resultsReserve>` — for the box the feed arrives into, which a host was holding with `#search-page > :last-child`. Measured with dependencies held constant, this package's src before and after: 32.10 -> 32.25 KB — 150 B, against a hand-guessed height, a sibling-count selector and two stylesheet rules a consumer can now delete. 32.75 KB HOLDS for the rail's own scrollbar and the block rhythm (owner's walk of the storefront, dark theme): measured 32539 B against the 32.75 KB line, 290 B over the 32249 B this note last recorded, and 211 B of room left. The rail stays its own scroll container — filters that stay put while the results move under them is the whole point of it — and `railScrollbar` names whose BAR draws in the gutter: `\"styled\"` (the new default) is a hoisted rule set in both vendor forms, a 6px track with no arrows and no track fill and a thumb that is transparent at rest and arrives from the tokens on hover or focus-within, standing always under `(pointer: coarse)` where neither fires; `\"system\"` hands the port back to the platform and mounts no sheet at all. The system bar was never a decision, which is why the default is the new arm. The rest is `blockRhythm`: one gap for every block on this page from `var(--stapel-block-gap)` / `var(--stapel-block-gap-compact)` with each block's outer margin reset, in place of the flat `spacing[4]` the root `<Flex>` wrote inline. A budget is not raised for a change that fits under the line it already has. 32.75 -> 33.25 KB for THE POINTER THAT WAS A PARTITION CHIP (owner's read of the stand at 0.38.0). Measured with dependencies held constant, this package's src before this change and then at it: 32539 -> 32779 B — 240 B, 29 of them over the old line, and a ceiling that fails on 29 B of a shipped defect fix is a gate proving nothing. `PartitionChild.linked`/`href` and `<PartitionChips linkedChildren>` split the row's items ONCE, above everything that reads them: the cells, the roving stop, the value lookup and the arrow keys see only the SECTIONS, so a pointer cannot be a radio, cannot carry a count and cannot be the chosen partition — the live row read `All | New 0 | Used 3 | Car rental 0`, where the last zero counted a category that is not a section of this template at all. The rest is the pointer's own chip: an outlined pill that is a real `<a href>` to the target, its inline arrow glyph (drawn here, like every other glyph in this skin — this package ships no icon set), and the row that carries them OUTSIDE the radiogroup, because a `role=\"radiogroup\"` containing a link announces a choice with an option nobody can choose. 33.25 KB leaves 471 B, then 33.25 -> 34 KB for THE TWO THINGS THE CLOSING-WAVE CENSUS FOUND ON THE FEED AND THE RAIL. Measured with dependencies held constant, this package's src before this change and then at it: 32779 -> 33584 B — 805 B, 334 of them over the old line. First, `<SearchPage toolbarSticky>` (default `true`): the results toolbar pins itself at `railTop` — the same edge the filter rail already clears, so there is no second number to keep in step — through a hoisted `@media (pointer: fine)` rule set, because the pin has to be gated on a pointer and a media query cannot be written in a `style` attribute. `stickyToolbar` shipped a release ago and no deployment turned it on, which is a feature nobody has; the reference pins its sort bar once a reader has scrolled into the results (REPORT §24, Surface 2) and this page now does too, on a desktop only — a pinned bar over a 390px viewport spends the fold on chrome. The row also states its own box from the first frame (`toolbarRowMinHeight(token.controlHeight)`, the discipline `chipRowMinHeight` is written under), so its height cannot change around the moment the rule engages. Second, the colour SWATCH: a facet whose axis is a colour (`isColorAxis` — the slug's head, the address key beside it, and `axis_role` the day the canon grows a colour one) draws a filled dot beside every value whose code it can resolve to an actual colour (`swatchColor` — a design-system colour role first, then CSS's own keyword vocabulary, then a hex code a catalogue spelled out itself), and NOTHING beside the rest. Most of the weight is that keyword list, and it is the whole point of the feature: it is the vocabulary in which \"this code names a colour\" is a fact rather than a guess, and without it the only alternatives are a hue table invented for one catalogue's transliterations or a grey placeholder saying every value is the same colour. 34 KB leaves 416 B. 34 KB HOLDS for the owner's tidiness probe on the stand (dark theme): measured 33661 B against the 34 KB line, 77 B over the 33584 B this note last recorded, and 339 B of room left. Three fills, all of them this pair drawing something the page had already decided. `<SearchPage railSurface>` / `<FacetPanelPane railSurface>` (default `\"flat\"`, `\"panel\"` restores the old arm): the filter panel's body painted the raised container ground and read as a 270 x 1539 filled slab with no radius and no border, standing on the page ground for the whole height of the feed — flat draws the controls and only the text colour a bare surface drops (`var(--stapel-text)`), the same answer `categories-react` gave for its grid, strip and breadcrumbs. The rail's footer bar stopped choosing a colour: it painted antd's `colorBgContainer` in both arms, and now paints the panel's OWN token and only in the pinned arm, which is the one with a scroll port under it — the static arm paints nothing and keeps its hairline. And `resultsHeader`'s wrapper is `display: contents`: it was mounted on the PROP rather than on what the prop rendered, so a host whose header said nothing still put a 1392 x 0 element in the block-rhythm column, which is charged a gap on BOTH sides — 64px between two real blocks where 32 is declared, on every feed page, plus a `:empty` stand-in rule in the consumer's stylesheet that can now be deleted. A budget is not raised for a change that fits under the line it already has",
61
61
  "path": "dist/default/index.js",
62
- "limit": "32.75 KB"
62
+ "limit": "34 KB"
63
63
  },
64
64
  {
65
65
  "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
@@ -115,8 +115,8 @@
115
115
  "size-limit": "^11.2.0",
116
116
  "typescript": "^5.8.3",
117
117
  "vitest": "^3.2.4",
118
- "@stapel/core": "^0.26.1",
119
118
  "@stapel/attributes-react": "^0.17.0",
119
+ "@stapel/core": "^0.26.1",
120
120
  "@stapel/image": "^0.4.2",
121
121
  "@stapel/showcase": "^0.3.0",
122
122
  "@stapel/tokens": "^0.8.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.38.0",
4
+ "version": "0.40.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -67,6 +67,7 @@ import type { CSSProperties, ReactElement } from "react";
67
67
  import { Button, Checkbox, Flex, Input, Typography } from "antd";
68
68
  import { useT } from "@stapel/core";
69
69
  import { SkinPickerSheet } from "@stapel/tokens-antd/skin";
70
+ import { SWATCH_SIZE, facetSwatch } from "./swatches.js";
70
71
  import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
71
72
  import { controls, cssVar, radii, spacing } from "@stapel/tokens";
72
73
  import { featureConfig, featureType } from "@stapel/attributes-react";
@@ -342,6 +343,42 @@ function OptionCount(props: {
342
343
  /** The indent one level of a hierarchical facet is drawn with. */
343
344
  const NEST_STEP = spacing[5];
344
345
 
346
+ /**
347
+ * The colour a value IS, drawn beside the word for it — see `./swatches.ts`.
348
+ *
349
+ * `aria-hidden`, and it never replaces the label: a dot is a second way to
350
+ * read a row that already reads, so a screen reader and a monochrome display
351
+ * lose nothing. The hairline is the panel's own border role, because a white
352
+ * swatch on a white rail is otherwise an empty hole.
353
+ */
354
+ function Swatch(props: {
355
+ readonly group: FacetGroup;
356
+ readonly value: string;
357
+ }): ReactElement | null {
358
+ const color = facetSwatch(props.group, props.value);
359
+ if (color === null) return null;
360
+ return (
361
+ <span
362
+ aria-hidden="true"
363
+ data-testid={`facet-swatch-${props.group.slug}-${props.value}`}
364
+ data-swatch={color}
365
+ style={{
366
+ display: "inline-block",
367
+ inlineSize: SWATCH_SIZE,
368
+ blockSize: SWATCH_SIZE,
369
+ flex: "0 0 auto",
370
+ borderRadius: radii.full,
371
+ background: color,
372
+ border: `1px solid ${cssVar("border")}`,
373
+ // The dot sits ON the text line rather than on the box's baseline,
374
+ // which is what keeps a 12px circle centred against a 14px label.
375
+ verticalAlign: "-0.125em",
376
+ marginInlineEnd: spacing[1],
377
+ }}
378
+ />
379
+ );
380
+ }
381
+
345
382
  function CheckboxRow(props: {
346
383
  readonly group: FacetGroup;
347
384
  readonly node: FacetOptionNode;
@@ -369,6 +406,7 @@ function CheckboxRow(props: {
369
406
  props.onToggle(group.slug, node.option.value);
370
407
  }}
371
408
  >
409
+ <Swatch group={group} value={node.option.value} />
372
410
  {node.option.label}
373
411
  </Checkbox>
374
412
  <OptionCount group={group} option={node.option} />
@@ -406,6 +444,7 @@ function OptionPill(props: {
406
444
  props.onToggle(group.slug, option.value);
407
445
  }}
408
446
  >
447
+ <Swatch group={group} value={option.value} />
409
448
  {option.count === null ? option.label : `${option.label} ${option.count}`}
410
449
  </Button>
411
450
  );
@@ -96,7 +96,7 @@ import {
96
96
  LoadList,
97
97
  SkinTheme,
98
98
  } from "@stapel/tokens-antd/skin";
99
- import { spacing } from "@stapel/tokens";
99
+ import { cssVar, spacing } from "@stapel/tokens";
100
100
  import { featureName } from "@stapel/attributes-react";
101
101
  import type { FeatureDef } from "@stapel/attributes-react";
102
102
  import type { SearchGeo } from "../api/types.js";
@@ -226,7 +226,32 @@ export interface GeoFilterSlotProps {
226
226
  readonly onChange: (geo: SearchGeo | null) => void;
227
227
  }
228
228
 
229
+ /**
230
+ * WHAT THE FILTER PANEL'S OWN BODY PAINTS — see
231
+ * {@link FacetPanelPaneProps.railSurface}.
232
+ */
233
+ export type SearchRailSurface = "flat" | "panel";
234
+
229
235
  export interface FacetPanelPaneProps extends ThemeModeProp {
236
+ /**
237
+ * WHAT THE PANEL'S OWN BODY PAINTS. Default `"flat"`.
238
+ *
239
+ * - `"flat"` — nothing. The panel is a column of controls standing on the
240
+ * page's own ground, and it takes only the text colour it needs
241
+ * (`var(--stapel-text)`), which a bare surface does not set;
242
+ * - `"panel"` — the raised container ground this pane painted until now.
243
+ *
244
+ * The default changed, and the measurement is why: on the stand's dark theme
245
+ * the rail was a **270 x 1539** filled slab with no radius and no border,
246
+ * standing on the page ground — a card shape with none of a card's edges,
247
+ * running the whole height of the feed beside it. The same read produced the
248
+ * same verdict for `categories-react`'s grid, strip and breadcrumbs, and the
249
+ * answer there was the same one: draw the controls, not a box around them.
250
+ *
251
+ * `"panel"` is the old arm kept whole, for a deployment whose page ground is
252
+ * an image or whose layout genuinely wants the filters on their own sheet.
253
+ */
254
+ readonly railSurface?: SearchRailSurface;
230
255
  /** The category's feature schema — the source of option LABELS, of which
231
256
  * slugs get a numeric range row, and of which slugs are a filter at all
232
257
  * (`isFacetableFeature`: an `imei` is counted and is not one). */
@@ -407,6 +432,9 @@ function RailFooterBar(props: {
407
432
  /** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
408
433
  * after the last group. See {@link FacetPanelPaneProps.footerBar}. */
409
434
  readonly position: "sticky" | "static";
435
+ /** What the panel around it paints, so a pinned bar takes the SAME ground
436
+ * rather than deciding one of its own. See {@link FacetPanelPaneProps.railSurface}. */
437
+ readonly railSurface: SearchRailSurface;
410
438
  }): ReactElement | null {
411
439
  const t = useT();
412
440
  const tPlural = useTPlural();
@@ -430,9 +458,25 @@ function RailFooterBar(props: {
430
458
  data-testid="facets-footer-bar"
431
459
  data-position={props.position}
432
460
  style={{
433
- ...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
434
- // Opaque, or the options scrolling under the bar read THROUGH it.
435
- background: token.colorBgContainer,
461
+ ...(props.position === "sticky"
462
+ ? {
463
+ position: "sticky",
464
+ bottom: 0,
465
+ /* THE GROUND IT IS ON, and only where it has to be opaque.
466
+ The bar used to paint antd's `colorBgContainer` in BOTH arms:
467
+ a second opinion about a colour its parent already decided,
468
+ and — since the panel's own body went flat — a lighter strip
469
+ standing across the foot of the rail on the stand's dark
470
+ theme. It now paints the same token the panel does and only
471
+ in the arm that is pinned over its own scroll port, where a
472
+ transparent floor lets the options read THROUGH it. The
473
+ static arm has nothing scrolling under it and paints
474
+ nothing. */
475
+ background: cssVar(
476
+ props.railSurface === "panel" ? "surface-raised" : "surface"
477
+ ),
478
+ }
479
+ : {}),
436
480
  borderBlockStart: `1px solid ${token.colorSplit}`,
437
481
  paddingBlockStart: spacing[2],
438
482
  display: "flex",
@@ -633,8 +677,19 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
633
677
  ? "none"
634
678
  : props.footerBar;
635
679
 
680
+ /* The panel's own ground — see `railSurface`. `"bare"` paints NOTHING, text
681
+ colour included, so the flat arm states the one property it still needs;
682
+ the theme's own custom property, so it follows the brand and the dark side
683
+ rather than freezing whichever mode mounted first. */
684
+ const railSurface: SearchRailSurface = props.railSurface ?? "flat";
685
+
636
686
  return (
637
- <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
687
+ <SkinTheme
688
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
689
+ {...(railSurface === "flat"
690
+ ? { surface: "bare" as const, style: { color: cssVar("text") } }
691
+ : {})}
692
+ >
638
693
  <FacetPanel
639
694
  {...(props.categoryFeatures !== undefined
640
695
  ? { categoryFeatures: props.categoryFeatures }
@@ -1155,6 +1210,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
1155
1210
  activeFilters={bag.activeFilters}
1156
1211
  clearAll={bag.clearAll}
1157
1212
  position={footerBar}
1213
+ railSurface={railSurface}
1158
1214
  />
1159
1215
  )}
1160
1216
  </Flex>