@stapel/search-react 0.42.0 → 0.42.2

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 (40) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  3. package/dist/default/FacetGroupControl.js +31 -7
  4. package/dist/default/FacetGroupControl.js.map +1 -1
  5. package/dist/default/PopularValues.d.ts +9 -0
  6. package/dist/default/PopularValues.d.ts.map +1 -1
  7. package/dist/default/PopularValues.js +45 -9
  8. package/dist/default/PopularValues.js.map +1 -1
  9. package/dist/default/SearchPage.d.ts +7 -42
  10. package/dist/default/SearchPage.d.ts.map +1 -1
  11. package/dist/default/SearchPage.js +9 -74
  12. package/dist/default/SearchPage.js.map +1 -1
  13. package/dist/default/SearchResultsPane.d.ts +28 -0
  14. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  15. package/dist/default/SearchResultsPane.js +34 -4
  16. package/dist/default/SearchResultsPane.js.map +1 -1
  17. package/dist/default/focusRing.d.ts +83 -0
  18. package/dist/default/focusRing.d.ts.map +1 -0
  19. package/dist/default/focusRing.js +37 -0
  20. package/dist/default/focusRing.js.map +1 -0
  21. package/dist/default/index.d.ts +5 -2
  22. package/dist/default/index.d.ts.map +1 -1
  23. package/dist/default/index.js +4 -2
  24. package/dist/default/index.js.map +1 -1
  25. package/dist/default/scrollbar.d.ts +89 -0
  26. package/dist/default/scrollbar.d.ts.map +1 -0
  27. package/dist/default/scrollbar.js +69 -0
  28. package/dist/default/scrollbar.js.map +1 -0
  29. package/llms.txt +1 -1
  30. package/manifest.json +1 -1
  31. package/nav-manifest.json +1 -1
  32. package/package.json +6 -6
  33. package/src/analytics/generated/events.json +1 -1
  34. package/src/default/FacetGroupControl.tsx +58 -1
  35. package/src/default/PopularValues.tsx +62 -8
  36. package/src/default/SearchPage.tsx +18 -77
  37. package/src/default/SearchResultsPane.tsx +35 -4
  38. package/src/default/focusRing.ts +100 -0
  39. package/src/default/index.ts +17 -0
  40. package/src/default/scrollbar.ts +119 -0
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
66
66
  import { Button, Flex } from "antd";
67
67
  import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
68
68
  import { useT, useTPlural } from "@stapel/core";
69
- import { breakpoints, cssVar, spacing } from "@stapel/tokens";
69
+ import { breakpoints, spacing } from "@stapel/tokens";
70
70
  import type { FeatureDef } from "@stapel/attributes-react";
71
71
  import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
72
72
  import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
@@ -78,6 +78,11 @@ import type { SearchGeo } from "../api/types.js";
78
78
  import { buildRangeGroups } from "../state/ranges.js";
79
79
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
80
80
  import { useRailFits } from "./railFit.js";
81
+ import {
82
+ RAIL_SCROLLBAR_CLASS,
83
+ RAIL_STYLE_HREF,
84
+ railScrollbarCss,
85
+ } from "./scrollbar.js";
81
86
  import { FacetPanelPane } from "./FacetPanelPane.js";
82
87
  import type {
83
88
  CategoryFilterSlotProps,
@@ -267,14 +272,19 @@ export const FILTERS_RAIL_WIDTH = 280;
267
272
  export const RAIL_CLASS = "stapel-search-rail";
268
273
 
269
274
  /**
270
- * The class that carries the SKIN's scrollbar present under
271
- * `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
272
- * one class apart and a stand can read which one is on screen.
275
+ * The skin's scrollbar, which is no longer only the RAIL's — the dictionary
276
+ * facet inside the panel is a scroll port too, and it kept the platform's bar
277
+ * painted over its count column. The rule set, the class, the `href` and the
278
+ * track's width now live in `./scrollbar.ts` so both ports mount one hoisted
279
+ * sheet; they are re-exported here unchanged because they are this package's
280
+ * published surface.
273
281
  */
274
- export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
275
-
276
- /** The `href` the hoisted rail sheet is deduplicated by. */
277
- export const RAIL_STYLE_HREF = "stapel-search-rail";
282
+ export {
283
+ RAIL_SCROLLBAR_CLASS,
284
+ RAIL_SCROLLBAR_WIDTH,
285
+ RAIL_STYLE_HREF,
286
+ railScrollbarCss,
287
+ } from "./scrollbar.js";
278
288
 
279
289
  /** Whose scrollbar the rail's own scroll port draws — see
280
290
  * {@link SearchPageProps.railScrollbar}. */
@@ -284,75 +294,6 @@ export type SearchRailScrollbar = "styled" | "system";
284
294
  * {@link SearchPageProps.railScroll}. */
285
295
  export type SearchRailScroll = "internal" | "page";
286
296
 
287
- /**
288
- * The scrollbar's track width, in CSS pixels.
289
- *
290
- * Not on the spacing scale on purpose, and not a spacing decision: this is the
291
- * thickness of a hairline instrument, the size every platform's own overlay
292
- * bar lands within, and the number the storefront's owner named. Six is thin
293
- * enough to read as part of the panel and thick enough to grab.
294
- */
295
- const RAIL_SCROLLBAR_WIDTH = 6;
296
-
297
- /**
298
- * The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
299
- *
300
- * The system bar was never a decision — it is what an `overflow-y: auto` box
301
- * gets when nobody says otherwise, and on the storefront it landed as a grey
302
- * chrome-coloured strip standing next to the filters in a dark theme. What it
303
- * is replaced with:
304
- *
305
- * - a 6px track with no arrows and no track fill — the rail's own hairline,
306
- * not a widget;
307
- * - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
308
- * (which is what a pointer scrolling inside it is doing) and on
309
- * `:focus-within` (which is what a keyboard is doing). A coarse pointer
310
- * fires neither, so under `(pointer: coarse)` the thumb stands — a touch
311
- * surface with an invisible scrollbar is a rail with no sign it has a tail;
312
- * - `scrollbar-gutter: stable`, so the panel's right edge does not move when
313
- * the thumb arrives.
314
- *
315
- * Both vendor forms, because they are not alternatives: Firefox reads
316
- * `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
317
- * read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
318
- * standard properties too.
319
- *
320
- * The colours are `--stapel-*` custom properties, which resolve per theme at
321
- * paint time — an inline colour or a `useToken()` value would freeze whichever
322
- * theme was mounted first. This design system's neutral vocabulary has no
323
- * `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
324
- * hairline every pane is separated by) and `text-subtle` is that role one step
325
- * stronger, which is what the thumb takes when a pointer is on the thumb
326
- * itself.
327
- *
328
- * Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
329
- * pseudo-element is unreachable from an inline style — the same reason
330
- * `<LocationSummaryLine>` hoists one.
331
- */
332
- export function railScrollbarCss(): string {
333
- const bar = `.${RAIL_SCROLLBAR_CLASS}`;
334
- const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
335
- const thumb = cssVar("border");
336
- const awake = `${bar}:hover,${bar}:focus-within`;
337
- return [
338
- // ── Firefox ────────────────────────────────────────────────────────────
339
- `${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
340
- `scrollbar-color:transparent transparent}`,
341
- `${awake}{scrollbar-color:${thumb} transparent}`,
342
- // ── WebKit / Chromium ──────────────────────────────────────────────────
343
- `${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
344
- `${bar}::-webkit-scrollbar-track{background:transparent}`,
345
- `${bar}::-webkit-scrollbar-thumb{background:transparent;` +
346
- `border-radius:${cssVar("radius-full")}}`,
347
- `${bar}:hover::-webkit-scrollbar-thumb,` +
348
- `${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
349
- `${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
350
- // ── A surface with no hover at all ─────────────────────────────────────
351
- `@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
352
- `${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
353
- ].join("\n");
354
- }
355
-
356
297
  const RAIL: CSSProperties = {
357
298
  flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
358
299
  // Both bounds, not just the upper one. `flex-shrink: 0` already holds the
@@ -159,6 +159,35 @@ export const RESULTS_TOOLBAR_TOP_VAR = "--stapel-search-toolbar-top";
159
159
  /** The `href` the hoisted toolbar sheet is deduplicated by. */
160
160
  export const RESULTS_TOOLBAR_STYLE_HREF = "stapel-search-toolbar";
161
161
 
162
+ /**
163
+ * THE LAYER A STICKY CHROME BAND PAINTS ON (D473).
164
+ *
165
+ * The row used to say `z-index: 1`, which is not a layer above the feed — it
166
+ * is a layer IN it. Measured on a live stand at 1440, scrolled 900px: a feed
167
+ * card's favourite overlay is `position: absolute; z-index: 2` and its photo
168
+ * counter is `z-index: 1`, so the heart painted over the pinned bar and the
169
+ * counter tied with it and won on document order. Reviewers read the same
170
+ * thing at 390, 768 and 1440 in both themes — a card's heart and its "1 of N"
171
+ * badge over the "105 listings / List / Sort" row, with the card underneath
172
+ * sliced mid-price.
173
+ *
174
+ * `20` is the fleet's existing number for exactly this shape: `listings-react`
175
+ * pins its condensed listing bar at `z-index: 20`, "above a page's own content
176
+ * and deliberately below the range a modal layer uses". `@stapel/tokens` ships
177
+ * NO z-index vocabulary (it carries colour roles, spacing, radii, breakpoints
178
+ * and type — no layering scale), so there is no role to read this from; the
179
+ * number is published here rather than written twice as a literal, and the two
180
+ * pins (the host's inline one and the default rule set) both read it, or they
181
+ * would drift into a bar that is over the feed on one arm and under it on the
182
+ * other.
183
+ *
184
+ * The ceiling is as load-bearing as the floor: `<PublicShell>`'s header is
185
+ * `z-index: 1000` and antd's popups start at `zIndexPopupBase` (1000), so the
186
+ * band has to stay well below both — a sort select must still open OVER its
187
+ * own bar, and the bar must still pass UNDER the header it is pinned beneath.
188
+ */
189
+ export const RESULTS_TOOLBAR_Z_INDEX = 20;
190
+
162
191
  /**
163
192
  * The default pin's rule set.
164
193
  *
@@ -175,8 +204,9 @@ export function toolbarStickyCss(): string {
175
204
  `${bar}{position:sticky;top:var(${RESULTS_TOOLBAR_TOP_VAR},0px);` +
176
205
  // Over the cards, under the page's own chrome — and under antd's popups,
177
206
  // so the sort select still opens over its own bar. Opaque, or the cards
178
- // scroll THROUGH the row.
179
- `z-index:1;background:${cssVar("surface")}}}`
207
+ // scroll THROUGH the row. See {@link RESULTS_TOOLBAR_Z_INDEX} for why the
208
+ // layer is what it is.
209
+ `z-index:${String(RESULTS_TOOLBAR_Z_INDEX)};background:${cssVar("surface")}}}`
180
210
  );
181
211
  }
182
212
 
@@ -356,8 +386,9 @@ function toolbarPinStyle(pin: SearchToolbarPin | undefined): CSSProperties | und
356
386
  position: "sticky",
357
387
  top: pin.top ?? 0,
358
388
  // Over the cards, under the page's own chrome — and under antd's popups,
359
- // so the sort select still opens over its own bar.
360
- zIndex: 1,
389
+ // so the sort select still opens over its own bar. The SAME layer the
390
+ // default rule set uses — see {@link RESULTS_TOOLBAR_Z_INDEX}.
391
+ zIndex: RESULTS_TOOLBAR_Z_INDEX,
361
392
  // Opaque, or the cards scroll THROUGH the bar. The theme's own surface
362
393
  // role, resolved at paint time, so it follows the brand and the dark side
363
394
  // without a second colour being written here.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A focus ring the KEYBOARD gets and the mouse does not.
3
+ *
4
+ * On a live storefront, clicking the "collapse" control under a facet group
5
+ * left a red outline standing around it until something else took focus. The
6
+ * ring itself is right — it is the design system's `:focus-visible` outline,
7
+ * and a keyboard must have it — but it was being drawn for a POINTER, which is
8
+ * the one modality that already knows where it clicked.
9
+ *
10
+ * ── Why `:focus-visible` alone is not the fix ─────────────────────────────
11
+ *
12
+ * The usual answer, "draw the ring on `:focus-visible` and not on `:focus`",
13
+ * was already true here: antd draws its button ring from
14
+ * `&:not(:disabled):focus-visible` and nothing in this pair or the storefront's
15
+ * sheet draws one on bare `:focus`. `:focus-visible` is a HEURISTIC, though,
16
+ * not a statement about the last input device: the engine may match it for a
17
+ * click when focus lands on an element whose surroundings have just changed
18
+ * under it — which is exactly what a disclosure toggle does to its own list.
19
+ * Suppressing the ring on `:focus:not(:focus-visible)` therefore fixes nothing
20
+ * on the very control that showed the defect.
21
+ *
22
+ * ── What this states instead ──────────────────────────────────────────────
23
+ *
24
+ * The modality, as a fact the control records for itself: a pointer press
25
+ * stamps `data-pointer-focus` on the element, and the first key it sees — or
26
+ * losing focus — clears it. The sheet then turns the ring OFF while that stamp
27
+ * is on, and leaves every other case to the design system. Tab to the control
28
+ * and the ring is there; click it and it is not; click it and then Tab back to
29
+ * it and it is there again, because the stamp died with the blur.
30
+ *
31
+ * The attribute is written straight to the node rather than held in state, on
32
+ * purpose: it is presentation the frame does not depend on, one control at a
33
+ * time, and a `useState` per facet button would re-render a rail of forty
34
+ * groups to paint an outline. React does not own this attribute, so nothing
35
+ * it renders fights over it.
36
+ *
37
+ * The class is doubled in the selector (`.c.c[…]`) for exactly one reason:
38
+ * specificity. antd's own ring is `.ant-btn:not(:disabled):focus-visible`
39
+ * (0,3,0); a single class plus the attribute plus the pseudo-class ties it, and
40
+ * a tie is settled by whichever stylesheet the engine saw last — which for a
41
+ * runtime CSS-in-JS design system is not a thing this pair gets to decide.
42
+ */
43
+ import type { CSSProperties } from "react";
44
+
45
+ /** The class the pointer-focus rule is hung on. */
46
+ export const POINTER_FOCUS_CLASS = "stapel-search-pointer-focus";
47
+
48
+ /** The attribute a pointer press stamps, and a key or a blur clears. */
49
+ export const POINTER_FOCUS_ATTR = "data-pointer-focus";
50
+
51
+ /** The `href` the hoisted focus sheet is deduplicated by (React 19). */
52
+ export const POINTER_FOCUS_STYLE_HREF = "stapel-search-pointer-focus";
53
+
54
+ /** See the module note. */
55
+ export function pointerFocusCss(): string {
56
+ const c = `.${POINTER_FOCUS_CLASS}`;
57
+ return `${c}${c}[${POINTER_FOCUS_ATTR}]:focus-visible{outline:none;box-shadow:none}`;
58
+ }
59
+
60
+ /**
61
+ * The handlers a control spreads to opt into the rule above.
62
+ *
63
+ * Typed against the DOM rather than against React's synthetic event so the
64
+ * same object fits a native `<button>` and antd's `<Button>` — both forward
65
+ * these three props to the element, which is the only thing this needs.
66
+ */
67
+ export interface PointerFocusProps {
68
+ readonly className: string;
69
+ readonly onPointerDown: (event: { currentTarget: Element }) => void;
70
+ readonly onKeyDown: (event: { currentTarget: Element }) => void;
71
+ readonly onBlur: (event: { currentTarget: Element }) => void;
72
+ }
73
+
74
+ /**
75
+ * Spread onto any control whose ring should be the keyboard's alone.
76
+ *
77
+ * A constant, not a hook: it closes over nothing, so one object serves every
78
+ * control in the tree and a facet group of forty rows allocates none.
79
+ */
80
+ export const POINTER_FOCUS: PointerFocusProps = {
81
+ className: POINTER_FOCUS_CLASS,
82
+ onPointerDown: (event) => {
83
+ event.currentTarget.setAttribute(POINTER_FOCUS_ATTR, "");
84
+ },
85
+ onKeyDown: (event) => {
86
+ event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
87
+ },
88
+ onBlur: (event) => {
89
+ event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
90
+ },
91
+ };
92
+
93
+ /** {@link POINTER_FOCUS} with a className of the caller's own merged in. */
94
+ export function pointerFocus(className: string): PointerFocusProps {
95
+ return { ...POINTER_FOCUS, className: `${className} ${POINTER_FOCUS_CLASS}` };
96
+ }
97
+
98
+ /** Nothing this module styles inline — exported so a host composing its own
99
+ * control can state the same intent without re-deriving the selector. */
100
+ export const POINTER_FOCUS_NO_RING: CSSProperties = { outline: "none" };
@@ -54,6 +54,21 @@ export {
54
54
  railStyle,
55
55
  } from "./SearchPage.js";
56
56
  export { RAIL_OFFSET_PROPERTY, useRailFits } from "./railFit.js";
57
+ export {
58
+ RAIL_SCROLLBAR_WIDTH,
59
+ SCROLL_GUTTER_INLINE_END,
60
+ SCROLL_LIST_INSET_BLOCK_START,
61
+ } from "./scrollbar.js";
62
+ export {
63
+ POINTER_FOCUS,
64
+ POINTER_FOCUS_ATTR,
65
+ POINTER_FOCUS_CLASS,
66
+ POINTER_FOCUS_NO_RING,
67
+ POINTER_FOCUS_STYLE_HREF,
68
+ pointerFocus,
69
+ pointerFocusCss,
70
+ } from "./focusRing.js";
71
+ export type { PointerFocusProps } from "./focusRing.js";
57
72
  export type {
58
73
  SearchPageProps,
59
74
  SearchFiltersLayout,
@@ -91,6 +106,7 @@ export {
91
106
  RESULTS_TOOLBAR_STICKY_CLASS,
92
107
  RESULTS_TOOLBAR_STYLE_HREF,
93
108
  RESULTS_TOOLBAR_TOP_VAR,
109
+ RESULTS_TOOLBAR_Z_INDEX,
94
110
  resultsColumnsCss,
95
111
  toolbarRowMinHeight,
96
112
  toolbarStickyCss,
@@ -178,6 +194,7 @@ export {
178
194
  POPULAR_VALUES_COLUMNS,
179
195
  POPULAR_VALUES_LADDER,
180
196
  POPULAR_VALUES_LIMIT,
197
+ POPULAR_VALUES_MAX_COLUMNS,
181
198
  POPULAR_VALUES_STYLE_HREF,
182
199
  POPULAR_VALUE_COLUMN_WIDTH,
183
200
  } from "./PopularValues.js";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The skin's scrollbar — one rule set, for every scroll port this pair owns.
3
+ *
4
+ * It started as the rail's own: `<SearchPage>` replaced the platform bar
5
+ * standing next to the filters with a 6px hairline in the token palette. But
6
+ * the rail is not the only box in the panel that scrolls — a dictionary facet
7
+ * (the make axis, 418 car makes) is a scroll port INSIDE the rail, and kept the
8
+ * platform's bar: on the live storefront the system thumb was painted over the
9
+ * count column, so «Chery 5» read as «Chery» with the 5 under a grey strip.
10
+ *
11
+ * A box that scrolls owes the reader two things, and both are here:
12
+ *
13
+ * - `scrollbar-gutter: stable`, so the space the bar needs is subtracted from
14
+ * the CONTENT box rather than overlaid on it, and the box's right edge does
15
+ * not move when the thumb arrives;
16
+ * - a thumb that is the skin's — a 6px track with no arrows and no track
17
+ * fill, transparent at rest, appearing on `:hover` (a pointer scrolling
18
+ * inside it) and `:focus-within` (a keyboard), and standing permanently
19
+ * under `(pointer: coarse)`, where neither fires and an invisible bar is a
20
+ * box with no sign it has a tail.
21
+ *
22
+ * Both vendor forms, because they are not alternatives: Firefox reads
23
+ * `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
24
+ * read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
25
+ * standard properties too.
26
+ *
27
+ * The colours are `--stapel-*` custom properties, which resolve per theme at
28
+ * paint time — an inline colour or a `useToken()` value would freeze whichever
29
+ * theme was mounted first. This design system's neutral vocabulary has no
30
+ * `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
31
+ * hairline every pane is separated by) and `text-subtle` is that role one step
32
+ * stronger, which is what the thumb takes when a pointer is on the thumb
33
+ * itself.
34
+ *
35
+ * Emitted as one hoisted `<style href precedence>` (React 19 dedupes by
36
+ * `href`), because a pseudo-element is unreachable from an inline style — so
37
+ * the rail and the dictionary list mount the SAME element, once, however many
38
+ * of each are on screen.
39
+ *
40
+ * The names still say "rail" and they stay that way: they are this package's
41
+ * published surface, and a hoisted sheet is identified by its `href`. Renaming
42
+ * them would break every host that reads the class to assert which bar is on
43
+ * screen, and would buy nothing but a tidier word.
44
+ */
45
+ import type { CSSProperties } from "react";
46
+ import { cssVar, spacing } from "@stapel/tokens";
47
+
48
+ /**
49
+ * The class that carries the SKIN's scrollbar — present on the rail under
50
+ * `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
51
+ * one class apart and a stand can read which one is on screen. Always present
52
+ * on a scroll port INSIDE the panel, which is not a surface a host chose.
53
+ */
54
+ export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
55
+
56
+ /** The `href` the hoisted rail sheet is deduplicated by. */
57
+ export const RAIL_STYLE_HREF = "stapel-search-rail";
58
+
59
+ /**
60
+ * The scrollbar's track width, in CSS pixels.
61
+ *
62
+ * Not on the spacing scale on purpose, and not a spacing decision: this is the
63
+ * thickness of a hairline instrument, the size every platform's own overlay
64
+ * bar lands within, and the number the storefront's owner named. Six is thin
65
+ * enough to read as part of the panel and thick enough to grab.
66
+ */
67
+ export const RAIL_SCROLLBAR_WIDTH = 6;
68
+
69
+ /** See the module note. */
70
+ export function railScrollbarCss(): string {
71
+ const bar = `.${RAIL_SCROLLBAR_CLASS}`;
72
+ const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
73
+ const thumb = cssVar("border");
74
+ const awake = `${bar}:hover,${bar}:focus-within`;
75
+ return [
76
+ // ── Firefox ────────────────────────────────────────────────────────────
77
+ `${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
78
+ `scrollbar-color:transparent transparent}`,
79
+ `${awake}{scrollbar-color:${thumb} transparent}`,
80
+ // ── WebKit / Chromium ──────────────────────────────────────────────────
81
+ `${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
82
+ `${bar}::-webkit-scrollbar-track{background:transparent}`,
83
+ `${bar}::-webkit-scrollbar-thumb{background:transparent;` +
84
+ `border-radius:${cssVar("radius-full")}}`,
85
+ `${bar}:hover::-webkit-scrollbar-thumb,` +
86
+ `${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
87
+ `${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
88
+ // ── A surface with no hover at all ─────────────────────────────────────
89
+ `@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
90
+ `${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
91
+ ].join("\n");
92
+ }
93
+
94
+ /**
95
+ * What a scroll port inside the panel reserves at its trailing edge.
96
+ *
97
+ * `scrollbar-gutter: stable` is the rule and the sheet above states it; this
98
+ * is the same number as a value, for the ROW to keep its last column clear of.
99
+ * `scrollbar-gutter` subtracts the gutter from the scroll container's content
100
+ * box, which is enough on every engine that honours it — and is ignored
101
+ * outright by Safari, where an overlay thumb still lands on top of whatever
102
+ * sits at the trailing edge. A count is the one thing in a facet row that
103
+ * lives there, so it carries the gutter as padding of its own: correct twice
104
+ * over on Chromium and Firefox, and the only thing standing between the number
105
+ * and the thumb on WebKit.
106
+ */
107
+ export const SCROLL_GUTTER_INLINE_END: CSSProperties = {
108
+ paddingInlineEnd: RAIL_SCROLLBAR_WIDTH,
109
+ };
110
+
111
+ /**
112
+ * The top of a list that sits directly under a control.
113
+ *
114
+ * The dictionary list's first row was drawn flush against the box that filters
115
+ * it — at a glance the row read as clipped BY the input rather than as the
116
+ * first value under it. One step of the scale is the whole fix; it is stated
117
+ * here rather than inline so the rail's two scroll ports open the same way.
118
+ */
119
+ export const SCROLL_LIST_INSET_BLOCK_START: number = spacing[1];