@stapel/listings-react 0.26.0 → 0.28.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 (80) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +102 -8
  4. package/dist/api/generated/schema.d.ts +20 -10
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/ListingActions.d.ts +8 -1
  7. package/dist/default/ListingActions.d.ts.map +1 -1
  8. package/dist/default/ListingActions.js +1 -1
  9. package/dist/default/ListingActions.js.map +1 -1
  10. package/dist/default/ListingDetailPane.d.ts +148 -9
  11. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  12. package/dist/default/ListingDetailPane.js +131 -19
  13. package/dist/default/ListingDetailPane.js.map +1 -1
  14. package/dist/default/MyListingsPane.d.ts.map +1 -1
  15. package/dist/default/MyListingsPane.js +10 -6
  16. package/dist/default/MyListingsPane.js.map +1 -1
  17. package/dist/default/ShareAction.d.ts +16 -1
  18. package/dist/default/ShareAction.d.ts.map +1 -1
  19. package/dist/default/ShareAction.js +16 -9
  20. package/dist/default/ShareAction.js.map +1 -1
  21. package/dist/default/actionRow.d.ts +24 -0
  22. package/dist/default/actionRow.d.ts.map +1 -1
  23. package/dist/default/actionRow.js +50 -7
  24. package/dist/default/actionRow.js.map +1 -1
  25. package/dist/default/detailGallery.d.ts +77 -0
  26. package/dist/default/detailGallery.d.ts.map +1 -0
  27. package/dist/default/detailGallery.js +91 -0
  28. package/dist/default/detailGallery.js.map +1 -0
  29. package/dist/default/index.d.ts +4 -2
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +5 -1
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/default/movableCluster.d.ts +18 -0
  34. package/dist/default/movableCluster.d.ts.map +1 -0
  35. package/dist/default/movableCluster.js +110 -0
  36. package/dist/default/movableCluster.js.map +1 -0
  37. package/dist/headless/MyListings.d.ts +31 -10
  38. package/dist/headless/MyListings.d.ts.map +1 -1
  39. package/dist/headless/MyListings.js +19 -2
  40. package/dist/headless/MyListings.js.map +1 -1
  41. package/dist/headless/Share.d.ts +60 -2
  42. package/dist/headless/Share.d.ts.map +1 -1
  43. package/dist/headless/Share.js +55 -3
  44. package/dist/headless/Share.js.map +1 -1
  45. package/dist/index.d.ts +4 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/cardBadges.d.ts +25 -0
  50. package/dist/model/cardBadges.d.ts.map +1 -1
  51. package/dist/model/cardBadges.js +41 -5
  52. package/dist/model/cardBadges.js.map +1 -1
  53. package/dist/model/draft.d.ts +42 -1
  54. package/dist/model/draft.d.ts.map +1 -1
  55. package/dist/model/draft.js +40 -3
  56. package/dist/model/draft.js.map +1 -1
  57. package/dist/model/status.d.ts +31 -16
  58. package/dist/model/status.d.ts.map +1 -1
  59. package/dist/model/status.js +30 -15
  60. package/dist/model/status.js.map +1 -1
  61. package/llms.txt +1 -1
  62. package/manifest.json +8 -1
  63. package/nav-manifest.json +1 -1
  64. package/package.json +3 -3
  65. package/src/analytics/generated/events.json +1 -1
  66. package/src/api/generated/schema.ts +20 -10
  67. package/src/default/ListingActions.tsx +9 -1
  68. package/src/default/ListingDetailPane.tsx +316 -18
  69. package/src/default/MyListingsPane.tsx +10 -6
  70. package/src/default/ShareAction.tsx +36 -10
  71. package/src/default/actionRow.ts +55 -7
  72. package/src/default/detailGallery.ts +97 -0
  73. package/src/default/index.ts +15 -1
  74. package/src/default/movableCluster.tsx +137 -0
  75. package/src/headless/MyListings.tsx +52 -12
  76. package/src/headless/Share.tsx +98 -5
  77. package/src/index.ts +7 -0
  78. package/src/model/cardBadges.ts +42 -5
  79. package/src/model/draft.ts +61 -3
  80. package/src/model/status.ts +31 -16
@@ -49,8 +49,34 @@
49
49
  * as CategoryPage's `subcategories`: a decision taken once by the component
50
50
  * that knows the viewport it granted, never a media query guessed in a leaf —
51
51
  * and the default `"column"` renders exactly what existing hosts already get.
52
+ *
53
+ * ── The reader's cluster can be in TWO places, and is ONE thing ────────────
54
+ *
55
+ * A phone reads this page over four screens. Past the first, the reference
56
+ * classified draws a condensed bar — back, the title, and the two verbs — and
57
+ * a container building one had to mount a `<ListingActions>` of its own,
58
+ * because `actionsPlacement` took a single value and the pane exposed no
59
+ * target for the cluster it builds. That second mount is a second
60
+ * `useFavoriteToggle` on one page: two hearts that agree only after a refetch,
61
+ * two `aria-pressed` controls, and a second set of test ids kept in step by
62
+ * hand so the pane's own stayed single.
63
+ *
64
+ * `actionsPlacement={["header", "bar"]}` + `renderActionsBar` is the answer,
65
+ * and the shape is deliberate: the render prop is handed a MOUNT POINT, not
66
+ * the cluster. `<ListingActions>` is rendered once through a portal and the
67
+ * portal's container is moved between the two slots as a DOM node, so the
68
+ * component mounts once, holds one hook, and is literally the same element in
69
+ * both places (`movableCluster.tsx` has the argument; the test holds the
70
+ * favourite across the move and compares identity). A render prop handed the
71
+ * cluster's ELEMENT would have read the same at a call site and mounted twice,
72
+ * which is the defect with the pair's name on it.
73
+ *
74
+ * `onTitleVisible` is the other half: the same container watched the pane's
75
+ * `<h1>` through its published test id and a `MutationObserver`, for a
76
+ * boolean the pane already knows. It is an `IntersectionObserver` on the
77
+ * title, never a scroll listener.
52
78
  */
53
- import { isValidElement } from "react";
79
+ import { isValidElement, useCallback, useEffect, useRef } from "react";
54
80
  import type { ReactElement, ReactNode } from "react";
55
81
  import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
56
82
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
@@ -76,11 +102,18 @@ import { useListingActions } from "../headless/ListingActions.js";
76
102
  import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
77
103
  import { formatSpecValue } from "../model/featureText.js";
78
104
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
79
- import type { ShareChannel } from "../headless/Share.js";
105
+ import type { ShareChannel, SharePreference } from "../headless/Share.js";
80
106
  import { GateReasonPopover } from "./GateReasonPopover.js";
81
107
  import { ListingActions } from "./ListingActions.js";
82
108
  import type { ListingActionsConfig } from "./ListingActions.js";
83
109
  import { LISTING_ACTION_CLASS } from "./actionRow.js";
110
+ import {
111
+ LISTINGS_GALLERY_CLASS,
112
+ LISTINGS_GALLERY_STYLE_HREF,
113
+ detailGalleryCss,
114
+ } from "./detailGallery.js";
115
+ import type { ListingGalleryLayout } from "./detailGallery.js";
116
+ import { useMovableCluster } from "./movableCluster.js";
84
117
  import { useNotice } from "./notice.js";
85
118
  import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
86
119
  import { SignInLink } from "./SignInLink.js";
@@ -113,12 +146,9 @@ export const DETAIL_SPLIT_MEASURE = "75rem";
113
146
  */
114
147
  export const DETAIL_SPLIT_ASIDE = "380px";
115
148
 
116
- /** The narrowest a gallery tile may get before the grid drops a column. A
117
- * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
118
- * which is §83's geometry rule — one photo per row on a phone, three on a
119
- * desktop pane, and no `width: 320` that is near-full-bleed on one and a
120
- * postage stamp on the other. */
121
- export const DETAIL_PHOTO_MIN = "14rem";
149
+ /** Re-exported: the tile floor is declared beside the track it feeds, in
150
+ * `detailGallery.ts`. */
151
+ export { DETAIL_PHOTO_MIN } from "./detailGallery.js";
122
152
 
123
153
  /**
124
154
  * THE GUTTER BETWEEN TWO PHOTOGRAPHS, and it is the page's own (D418).
@@ -241,6 +271,14 @@ export interface ListingDetailPaneProps
241
271
  * somebody sends to a friend. See `useShare`.
242
272
  */
243
273
  readonly shareUrl?: string;
274
+ /**
275
+ * Which arm the share control uses — handed to `<ShareAction prefer>`
276
+ * through the cluster. Default `"auto"`: the platform sheet where the
277
+ * primary pointer is coarse, this pair's menu on a mouse. See
278
+ * `SharePreference` for the measurement that made the pointer part of the
279
+ * question (§25).
280
+ */
281
+ readonly sharePrefer?: SharePreference;
244
282
  /** Analytics: which channel a completed share went through. */
245
283
  readonly onShared?: (channel: ShareChannel) => void;
246
284
  /**
@@ -262,9 +300,96 @@ export interface ListingDetailPaneProps
262
300
  * photo counter owns the bottom trailing corner;
263
301
  * - `"buy-box"` — inside `listings-detail-actions`, beside "message the
264
302
  * seller", which is where the favourite alone used to live. The escape
265
- * hatch for a host whose page was laid out around it.
303
+ * hatch for a host whose page was laid out around it;
304
+ * - `"bar"` — the condensed top bar the host draws through
305
+ * {@link renderActionsBar}. Only meaningful in a LIST beside one of the
306
+ * three above, and only with that render prop: it names a second place
307
+ * the one cluster may travel to, never a home of its own.
308
+ *
309
+ * A LIST is how a host says "both": `["header", "bar"]` keeps the cluster
310
+ * beside the title and lends it to the bar for as long as the bar is on
311
+ * screen. Exactly one home placement is honoured — the first non-`"bar"`
312
+ * entry — because two homes would need two instances, which is the defect
313
+ * this closes rather than the feature it adds.
314
+ */
315
+ readonly actionsPlacement?:
316
+ | ListingActionsPlacement
317
+ | readonly ListingActionsPlacement[];
318
+ /**
319
+ * THE SAME CLUSTER, IN A SECOND PLACE — a condensed bar, typically.
320
+ *
321
+ * Called with the bar's MOUNT POINT, not with a copy of the cluster: return
322
+ * it wrapped in whatever chrome the bar is (`position: fixed`, a back arrow,
323
+ * the title), and the pane moves its one `<ListingActions>` into it. Return
324
+ * `null` while the bar is not on screen and the cluster goes back where it
325
+ * came from — the same DOM node, the same hooks, an optimistic favourite
326
+ * still in flight uninterrupted.
327
+ *
328
+ * Requires `"bar"` in {@link actionsPlacement}. Pair it with
329
+ * {@link onTitleVisible} for the usual rule: the bar appears when the title
330
+ * leaves the fold.
331
+ *
332
+ * ```tsx
333
+ * <ListingDetailPane
334
+ * actionsPlacement={["header", "bar"]}
335
+ * onTitleVisible={(visible) => { setBarShown(!visible); }}
336
+ * renderActionsBar={(cluster) =>
337
+ * barShown ? <div className="topbar">{back}{title}{cluster}</div> : null
338
+ * }
339
+ * />
340
+ * ```
341
+ *
342
+ * A container that mounted its own second `<ListingActions>` for this can
343
+ * delete it: two `useFavoriteToggle` instances on one page, two hearts that
344
+ * agree only after a refetch, and a second set of test ids to keep the
345
+ * pane's own single are all what this prop exists to end.
266
346
  */
267
- readonly actionsPlacement?: "header" | "gallery" | "buy-box";
347
+ readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
348
+ /**
349
+ * IS THE TITLE STILL IN THE FOLD?
350
+ *
351
+ * An `IntersectionObserver` on the pane's own `<h1>` — never a `scroll`
352
+ * listener, which asks the question on every frame of a page whose job is
353
+ * scrolling photographs and answers it no better. Called on each crossing
354
+ * and not on every scroll: `false` when the title leaves, `true` when it
355
+ * comes back.
356
+ *
357
+ * It exists because the chrome a host hangs on this had no way to ask. A
358
+ * container drawing a condensed bar found the title by the pane's published
359
+ * `data-testid` and waited for it with a `MutationObserver`, because the
360
+ * title lands with the listing and not with the first frame — a private
361
+ * selector and a subscription, for a boolean the pane already knows.
362
+ *
363
+ * NOT called at all where the environment has no `IntersectionObserver`:
364
+ * the honest answer there is "this page cannot tell", and a fabricated
365
+ * `true` would leave a host's bar wedged open on the arm that has no
366
+ * scrolling anyway.
367
+ *
368
+ * Pass a STABLE function (a `useState` setter, a `useCallback`). The
369
+ * observer is created once for the title node and reads the latest callback
370
+ * through a ref, so an inline arrow works and does not re-observe.
371
+ */
372
+ readonly onTitleVisible?: (visible: boolean) => void;
373
+ /**
374
+ * WHAT SHAPE THE PHOTOGRAPHS ARE IN.
375
+ *
376
+ * - `"grid"` (default) — the element-width grid this pane has always drawn,
377
+ * `repeat(auto-fit, minmax(14rem, 1fr))`: three tiles across a desktop
378
+ * pane, one across a phone;
379
+ * - `"strip"` — a snap-scrolling horizontal strip, one photograph visible
380
+ * with the next peeking. On a 390px phone the grid resolves to one
381
+ * column, so a listing with three pictures pushes its own title and price
382
+ * nearly three screens down — the first thing a person sees after tapping
383
+ * a search result is a photograph with nothing beside it.
384
+ *
385
+ * The HOST names it, the same rule as {@link layout} and for the same
386
+ * reason: the side that knows the viewport it granted decides, and no media
387
+ * query is guessed in a leaf. A live storefront was carrying
388
+ * `display: flex !important` against this pane's inline `display: grid` to
389
+ * say exactly this; that declaration is a class now, so even a host wanting
390
+ * a third shape needs a selector rather than an `!important`.
391
+ */
392
+ readonly galleryLayout?: ListingGalleryLayout;
268
393
  /**
269
394
  * The container's sign-in door, rendered beside the favourite's refusal —
270
395
  * the same `SignInCta` seam the three card skins already take. The pane was
@@ -295,9 +420,128 @@ export interface ListingDetailPaneProps
295
420
  * Default `3`, byte-compatible for every existing mount.
296
421
  */
297
422
  readonly headingLevel?: 1 | 2 | 3;
423
+ /**
424
+ * WHERE THE BUY COLUMN'S STICKY TOP EDGE IS — the offset of whatever chrome
425
+ * is pinned above this page (`layout="split"` only; the one-column arm has
426
+ * no sticky column).
427
+ *
428
+ * ```tsx
429
+ * // the height <PublicShell> publishes, read rather than restated
430
+ * <ListingDetailPane layout="split" buyTop="var(--stapel-header-height)" />
431
+ * ```
432
+ *
433
+ * The column is `position: sticky; top: 16px` written INLINE, and an inline
434
+ * declaration is beaten by nothing short of `!important` — so a host with a
435
+ * pinned header had no way to say "start below it". Measured on the stand
436
+ * (D456): the storefront's header is sticky and 64px tall, and at any scroll
437
+ * depth the top of the buy column — the price's own first twenty pixels —
438
+ * sat UNDER it. Same seam and same argument as `<SearchPage railTop>`, which
439
+ * this prop is deliberately spelled after.
440
+ *
441
+ * A number is pixels; a string is taken as written (a `var()`, a `calc()`,
442
+ * `"4rem"`). Default `spacing[4]` — 16px, exactly where the column has
443
+ * always started — so an existing mount is byte-compatible.
444
+ */
445
+ readonly buyTop?: number | string;
446
+ /**
447
+ * HOW WIDE THE PANE MAY GET — the `max-width` it writes on its own root.
448
+ *
449
+ * Default is the constant for the arm on screen: {@link DETAIL_MEASURE}
450
+ * (60rem) in `"column"`, {@link DETAIL_SPLIT_MEASURE} (75rem) in `"split"`,
451
+ * so no existing mount changes shape. Anything CSS `max-width` takes is
452
+ * accepted (`"80rem"`, `1280`, `"100%"`).
453
+ *
454
+ * `"none"` removes the cap, and it is the answer for a pane mounted inside
455
+ * a page frame that already decided the measure. That case is not
456
+ * hypothetical: measured on the stand at 1440 (D457), the listing page's
457
+ * content ended at x=1224 with 216px of empty gutter beside it while every
458
+ * other page of the same site ran to the frame's edge — a second, lower cap
459
+ * inside a container that already had one. The cap is written INLINE, so
460
+ * the container could not outrank it without `!important` and reached for
461
+ * `min-inline-size: 100%` instead (a minimum beats a maximum by the sizing
462
+ * rules); this prop is that workaround's replacement, and the same seam
463
+ * `<CategoryPage measure>` already offers.
464
+ */
465
+ readonly measure?: number | string;
298
466
  readonly footer?: ReactNode;
299
467
  }
300
468
 
469
+ /**
470
+ * Where the reader's cluster may sit. Three homes and one loan — see
471
+ * {@link ListingDetailPaneProps.actionsPlacement}.
472
+ */
473
+ export type ListingActionsPlacement =
474
+ | "header"
475
+ | "gallery"
476
+ | "buy-box"
477
+ | "bar";
478
+
479
+ /** The cluster's HOME: the first entry that is not the borrowed bar. */
480
+ function homePlacement(
481
+ placement: ListingDetailPaneProps["actionsPlacement"]
482
+ ): Exclude<ListingActionsPlacement, "bar"> {
483
+ if (placement === undefined) return "header";
484
+ if (typeof placement === "string") {
485
+ // `"bar"` alone names no home — the cluster still has to live somewhere
486
+ // while the bar is off screen, and that somewhere is the default.
487
+ return placement === "bar" ? "header" : placement;
488
+ }
489
+ for (const one of placement) {
490
+ if (one !== "bar") return one;
491
+ }
492
+ return "header";
493
+ }
494
+
495
+ /** Did the host ask for the borrowed placement at all? */
496
+ function wantsBar(
497
+ placement: ListingDetailPaneProps["actionsPlacement"]
498
+ ): boolean {
499
+ if (placement === undefined) return false;
500
+ if (typeof placement === "string") return placement === "bar";
501
+ return placement.includes("bar");
502
+ }
503
+
504
+ /** Priorities for the two mount points: the bar wins while it is on screen. */
505
+ const CLUSTER_HOME = 0;
506
+ const CLUSTER_BAR = 1;
507
+
508
+ /**
509
+ * The pane's own title, watched — see
510
+ * {@link ListingDetailPaneProps.onTitleVisible}.
511
+ *
512
+ * Returns a callback ref for the heading element. The observer is created once
513
+ * per node and disconnected by React 19's ref cleanup; the host's callback is
514
+ * read through a ref at call time, so an inline arrow does not re-observe on
515
+ * every render of a page that re-renders on every query update.
516
+ */
517
+ function useTitleVisibility(
518
+ onTitleVisible: ((visible: boolean) => void) | undefined
519
+ ): (node: HTMLElement | null) => (() => void) | undefined {
520
+ const latest = useRef(onTitleVisible);
521
+ useEffect(() => {
522
+ latest.current = onTitleVisible;
523
+ });
524
+ const wanted = onTitleVisible !== undefined;
525
+ return useCallback(
526
+ (node: HTMLElement | null): (() => void) | undefined => {
527
+ if (node === null || !wanted) return undefined;
528
+ // No observer, no answer. A fabricated `true` would wedge a host's bar
529
+ // open on an arm that has no scrolling to close it with.
530
+ if (typeof IntersectionObserver === "undefined") return undefined;
531
+ const observer = new IntersectionObserver((entries) => {
532
+ const entry = entries[entries.length - 1];
533
+ if (entry === undefined) return;
534
+ latest.current?.(entry.isIntersecting);
535
+ });
536
+ observer.observe(node);
537
+ return () => {
538
+ observer.disconnect();
539
+ };
540
+ },
541
+ [wanted]
542
+ );
543
+ }
544
+
301
545
  /**
302
546
  * Which arm of `actions` this is.
303
547
  *
@@ -335,7 +579,18 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
335
579
  // leaves rendering the reason to `<GatedControl>`, which computes its own.
336
580
  const favoriteView = useActionGate(bag.favoriteGate);
337
581
  const split = props.layout === "split";
338
- const placement = props.actionsPlacement ?? "header";
582
+ const placement = homePlacement(props.actionsPlacement);
583
+ /* THE SECOND PLACEMENT IS A LOAN, NOT A COPY. Both halves have to be asked
584
+ for: `"bar"` in the placement list says the cluster may travel, and
585
+ `renderActionsBar` is the only thing that can put it anywhere. With
586
+ neither — every existing mount — nothing below changes: one cluster,
587
+ rendered inline where it always was, no portal and no slot divs. */
588
+ const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
589
+ const renderBar = props.renderActionsBar;
590
+ const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
591
+ const movable = useMovableCluster(barred);
592
+ const moving = barred && movable.portable;
593
+ const titleRef = useTitleVisibility(props.onTitleVisible);
339
594
  // The two arms of `actions` — see `isActionsConfig`.
340
595
  const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
341
596
  props.actions
@@ -388,7 +643,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
388
643
  <SkinTheme
389
644
  surface="base"
390
645
  style={{
391
- maxWidth: split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE,
646
+ maxWidth: props.measure ?? (split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE),
392
647
  // See `gutter`: a frame that already placed the page edge does not get
393
648
  // a second one stacked inside it.
394
649
  padding: props.gutter === "shell" ? 0 : spacing[4],
@@ -566,18 +821,55 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
566
821
  {...(listing.title !== undefined && listing.title !== null
567
822
  ? { shareTitle: listing.title }
568
823
  : {})}
824
+ {...(props.sharePrefer !== undefined
825
+ ? { sharePrefer: props.sharePrefer }
826
+ : {})}
569
827
  {...(props.onShared !== undefined ? { onShared: props.onShared } : {})}
570
828
  />
571
829
  );
572
830
 
831
+ /* What the PAGE draws where the cluster lives. With the bar in
832
+ play that is a slot and not the cluster itself: the cluster is
833
+ rendered once into `clusterLayer` below and travels between the
834
+ two slots as a DOM node, so it mounts once, holds one
835
+ `useFavoriteToggle`, and keeps its element identity across the
836
+ move. Without it, the cluster is drawn inline exactly as it has
837
+ always been. */
838
+ const homeActions = moving
839
+ ? movable.slot(CLUSTER_HOME, placement)
840
+ : readerActions;
841
+
842
+ /* The one instance, plus the host's bar around the slot that may
843
+ borrow it. Rendered at the end of the page's own flow: the
844
+ portal has no position of its own (its content is wherever the
845
+ winning slot is), and a bar is `position: fixed` chrome whose
846
+ place in the document order is not its place on the screen. */
847
+ const clusterLayer =
848
+ !moving || renderBar === undefined ? null : (
849
+ <>
850
+ {movable.render(readerActions)}
851
+ {/* NOT a slot with a silent absence: `renderBar` is the only
852
+ thing that makes `moving` true, so this arm is
853
+ unreachable without one and the host's own `null` (the
854
+ bar off screen) is the answer that sends the cluster
855
+ home. There is no hole to place a `<SlotPlaceholder>` in
856
+ — the cluster is at its primary placement instead. */}
857
+ {renderBar(movable.slot(CLUSTER_BAR, "bar"))}
858
+ </>
859
+ );
860
+
573
861
  /* Element-width tiles: the grid decides how many fit, the
574
862
  photos fill them. */
575
863
  const gallery = (
576
864
  <div
577
865
  data-testid="listings-detail-gallery"
866
+ className={LISTINGS_GALLERY_CLASS}
867
+ // The layout is a CLASS and an attribute, not an inline
868
+ // `display`: a host with a shape neither arm offers can then
869
+ // write CSS for it at its own breakpoints without `!important`
870
+ // over a pair's own geometry. See `detailGallery.ts`.
871
+ data-gallery-layout={galleryLayout}
578
872
  style={{
579
- display: "grid",
580
- gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
581
873
  // The page's own edge, per breakpoint — see
582
874
  // `DETAIL_GALLERY_GUTTER` (D418).
583
875
  gap: DETAIL_GALLERY_GUTTER,
@@ -587,6 +879,9 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
587
879
  position: "relative",
588
880
  }}
589
881
  >
882
+ <style href={LISTINGS_GALLERY_STYLE_HREF} precedence="default">
883
+ {detailGalleryCss()}
884
+ </style>
590
885
  {bag.images.length === 0 ? (
591
886
  <ListingPhoto
592
887
  imageRef={undefined}
@@ -604,7 +899,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
604
899
  />
605
900
  ))
606
901
  )}
607
- {placement === "gallery" ? readerActions : null}
902
+ {placement === "gallery" ? homeActions : null}
608
903
  </div>
609
904
  );
610
905
 
@@ -621,12 +916,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
621
916
  <Flex align="flex-start" justify="space-between" gap={spacing[3]}>
622
917
  <Typography.Title
623
918
  level={props.headingLevel ?? 3}
919
+ ref={titleRef}
624
920
  data-testid="listings-detail-title"
625
921
  style={{ minWidth: 0, flex: "1 1 auto" }}
626
922
  >
627
923
  {listing.title ?? ""}
628
924
  </Typography.Title>
629
- {placement === "header" ? readerActions : null}
925
+ {placement === "header" ? homeActions : null}
630
926
  </Flex>
631
927
 
632
928
  {/* The `show_at_title` projection, formatted from the stored
@@ -741,7 +1037,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
741
1037
  {/* The reader's two actions live in the cluster now (see
742
1038
  `actionsPlacement`); the buy box keeps them only when a
743
1039
  host asks for the layout this page used to have. */}
744
- {placement === "buy-box" ? readerActions : null}
1040
+ {placement === "buy-box" ? homeActions : null}
745
1041
 
746
1042
  {actionsNode}
747
1043
  </Flex>
@@ -888,6 +1184,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
888
1184
  {meta}
889
1185
  {asideAfterActions ? null : aside}
890
1186
  {props.footer}
1187
+ {clusterLayer}
891
1188
  </>
892
1189
  );
893
1190
  }
@@ -928,7 +1225,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
928
1225
  data-testid="listings-detail-buy-column"
929
1226
  style={{
930
1227
  position: "sticky",
931
- top: spacing[4],
1228
+ top: props.buyTop ?? spacing[4],
932
1229
  alignSelf: "start",
933
1230
  }}
934
1231
  >
@@ -938,6 +1235,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
938
1235
  {aside}
939
1236
  </Flex>
940
1237
  </div>
1238
+ {clusterLayer}
941
1239
  </>
942
1240
  );
943
1241
  },
@@ -23,8 +23,11 @@
23
23
  * row was on the page (in a block above the tabs) and in no tab and in no
24
24
  * number, and a person reads the numbers.
25
25
  *
26
- * So the takedowns are the fourth tab, counted from their own read — see
27
- * `model/status.ts` for why a fourth tab and not the archive. The block
26
+ * So the takedowns are the fourth tab. It was counted from its own read
27
+ * while the counter had three integers; since stapel-listings 0.22.4 the
28
+ * counter has four and the badge is the SERVER's, so the tab appears and
29
+ * is right without the takedown page having landed — see `model/status.ts`
30
+ * for why a fourth tab and not the archive. The block
28
31
  * above the tabs stays as one LINE, without the rows: a takedown must not
29
32
  * need a click to be discovered, and printing the same row twice on one
30
33
  * screen is not the way to say so.
@@ -571,10 +574,11 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
571
574
  // phone instead of collapsing into an overflow menu.
572
575
  //
573
576
  // `tabCounts`, not `counters`: the badge is never allowed to
574
- // read lower than the rows underneath it, and the fourth tab
575
- // has no server counter at all (D407 a moderator-rejected
576
- // listing sat in Drafts under a `0`, and a taken-down one
577
- // under no number whatever).
577
+ // read lower than the rows underneath it, and the fourth
578
+ // tab's number degrades to its own rows on a server older
579
+ // than 0.22.4 (D407 a moderator-rejected listing sat in
580
+ // Drafts under a `0`, and a taken-down one under no number
581
+ // whatever).
578
582
  ready: (counts) => (
579
583
  <Typography.Text
580
584
  type={tab === "removed" ? "warning" : "secondary"}
@@ -10,16 +10,22 @@
10
10
  *
11
11
  * ── One control, two renderings, and the DEVICE picks ─────────────────────
12
12
  *
13
- * Where `navigator.share` exists which is every phone and almost no desktop
14
- * the press opens the PLATFORM's own sheet: the person's own apps, in their
15
- * own order, including the ones we have never heard of. A library that drew
16
- * its own list of four networks on a phone would be offering a worse version
17
- * of something the operating system already does better.
13
+ * On a device driven with a THUMB the press opens the PLATFORM's own sheet:
14
+ * the person's own apps, in their own order, including the ones we have never
15
+ * heard of. A library that drew its own list of four networks on a phone would
16
+ * be offering a worse version of something the operating system already does
17
+ * better.
18
18
  *
19
- * Where it does not, the press opens a small menu: copy the link, and the
20
- * three networks a Russian-speaking marketplace actually receives traffic
21
- * from. This is the DESKTOP rendering, not a fallback for old browsers, which
22
- * is why it is built rather than apologised for.
19
+ * Everywhere else the press opens a small menu: copy the link, and the three
20
+ * networks a Russian-speaking marketplace actually receives traffic from. This
21
+ * is the DESKTOP rendering, not a fallback for old browsers, which is why it
22
+ * is built rather than apologised for.
23
+ *
24
+ * The reading that decides is the primary POINTER and not `navigator.share`
25
+ * alone: desktop Chrome on macOS has the API, so the capability question sent
26
+ * every desktop share to the OS sheet and left this menu unreachable on the
27
+ * platform it was written for (§25). {@link ShareActionProps.prefer} is where
28
+ * a host overrules the reading in either direction.
23
29
  *
24
30
  * ── The menu is a Popover, and that is an exception with an argument ──────
25
31
  *
@@ -60,7 +66,11 @@ import { SkinButton as Button, ErrorAlert } from "@stapel/tokens-antd/skin";
60
66
  import { useT } from "@stapel/core";
61
67
  import { spacing } from "@stapel/tokens";
62
68
  import { useShare } from "../headless/Share.js";
63
- import type { ShareChannel, ShareNetwork } from "../headless/Share.js";
69
+ import type {
70
+ ShareChannel,
71
+ ShareNetwork,
72
+ SharePreference,
73
+ } from "../headless/Share.js";
64
74
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
65
75
  import {
66
76
  LISTING_ACTIONS_STYLE_HREF,
@@ -104,6 +114,21 @@ export interface ShareActionProps {
104
114
  * for it.
105
115
  */
106
116
  readonly shape?: "default" | "circle";
117
+ /**
118
+ * WHICH ARM THIS SURFACE WANTS — see {@link SharePreference}.
119
+ *
120
+ * Default `"auto"`: the platform sheet where the primary pointer is coarse
121
+ * AND `navigator.share` exists, this pair's menu everywhere else. The
122
+ * default changed in this release, and it changed because of a measurement
123
+ * (§25): desktop Chrome on macOS reports `navigator.share`, so a pair that
124
+ * asked only the capability opened the OS sheet on the desktop and the
125
+ * copy-link menu — three networks and a clipboard row, built for exactly
126
+ * that platform — could not be reached there at all.
127
+ *
128
+ * `"native"` restores the capability-only behaviour by name; `"menu"` pins
129
+ * this pair's menu on every device.
130
+ */
131
+ readonly prefer?: SharePreference;
107
132
  readonly style?: CSSProperties;
108
133
  }
109
134
 
@@ -115,6 +140,7 @@ export function ShareAction(props: ShareActionProps): ReactElement {
115
140
  url: props.url,
116
141
  title: props.title,
117
142
  text: props.text,
143
+ ...(props.prefer !== undefined ? { prefer: props.prefer } : {}),
118
144
  ...(props.onShared !== undefined ? { onShared: props.onShared } : {}),
119
145
  });
120
146
  const label = t(LISTINGS_I18N_KEYS.shareAction);
@@ -86,13 +86,62 @@ export const LISTING_ACTION_HIT: number = controls["height-phone"];
86
86
  */
87
87
  export const LISTING_CARD_ACTION_HIT = 36;
88
88
 
89
+ /**
90
+ * HOW MANY TIMES THE HIT-TARGET CLASS IS REPEATED IN ITS OWN SELECTOR — and
91
+ * why a repeat rather than a number typed once (D450).
92
+ *
93
+ * Measured on the live listing page: the heart and the share glyph were
94
+ * **32 × 44**, not 44 × 44. The block axis survived and the inline one did
95
+ * not, because antd's circle shape ships
96
+ *
97
+ * `:where(…).ant-btn.ant-btn-circle.ant-btn{min-width:var(--ant-control-height)}`
98
+ *
99
+ * — three classes, specificity (0,3,0), against this sheet's single class
100
+ * (0,1,0). `:where()` adds nothing, and a media query adds nothing either, so
101
+ * the only thing that decides is the class count: antd's 32 won and there was
102
+ * no viewport at which it did not.
103
+ *
104
+ * A repeated class is the one way to outrank it without `!important`. Four
105
+ * repeats — (0,4,0) — clear antd's three with one to spare, and the selector
106
+ * still matches exactly the same element, so nothing about WHAT the rule
107
+ * applies to changes. `!important` was refused deliberately: a host that
108
+ * genuinely wants a different target must be able to say so with a selector,
109
+ * and this sheet's whole argument (see `movableCluster.tsx`) is that a pair's
110
+ * geometry should never force one on somebody else.
111
+ */
112
+ export const LISTING_ACTION_SPECIFICITY = 4;
113
+
114
+ /** A class name repeated {@link LISTING_ACTION_SPECIFICITY} times — the
115
+ * selector that beats antd's circle. */
116
+ function outranking(className: string): string {
117
+ return `.${className}`.repeat(LISTING_ACTION_SPECIFICITY);
118
+ }
119
+
120
+ /**
121
+ * The floor, in BOTH spellings of the same axis.
122
+ *
123
+ * A browser cascades `min-inline-size` and `min-width` together and keeps the
124
+ * winner, so in a browser the logical pair alone would be enough once the
125
+ * selector outranks antd's. The physical pair is written beside it because
126
+ * the declaration being beaten is spelled physically, and an engine that does
127
+ * NOT merge the two names (jsdom, where this rule is asserted) would leave
128
+ * antd's `min-width:32px` standing beside our `min-inline-size:44px` and call
129
+ * that a pass. Two spellings of one number, and the number has one source.
130
+ */
131
+ function floor(size: number): string {
132
+ const px = `${String(size)}px`;
133
+ return (
134
+ `min-inline-size:${px};min-block-size:${px};` +
135
+ `min-width:${px};min-height:${px}`
136
+ );
137
+ }
138
+
89
139
  export function actionRowCss(): string {
90
- const action = `.${LISTING_ACTION_CLASS}`;
91
- const cardAction = `.${LISTING_CARD_ACTION_CLASS}`;
140
+ const action = outranking(LISTING_ACTION_CLASS);
141
+ const cardAction = outranking(LISTING_CARD_ACTION_CLASS);
92
142
  const row = `.${LISTING_ACTIONS_CLASS}`;
93
143
  const over = `.${LISTING_ACTIONS_OVERLAY_CLASS}`;
94
144
  const label = `.${LISTING_ACTION_LABEL_CLASS}`;
95
- const hit = String(LISTING_ACTION_HIT);
96
145
  const phone = `(max-width:${String(breakpoints.tablet - 1)}px)`;
97
146
  return [
98
147
  // The floor. `min-*` rather than `width`/`height`: a share button with a
@@ -100,14 +149,13 @@ export function actionRowCss(): string {
100
149
  // `controlHeight` already reaches the height on a phone — this is the
101
150
  // guarantee for every OTHER viewport and for a host-registered button
102
151
  // that never read the antd token at all.
103
- `${action}{min-inline-size:${hit}px;min-block-size:${hit}px;` +
152
+ `${action}{${floor(LISTING_ACTION_HIT)};` +
104
153
  `display:inline-flex;align-items:center;justify-content:center}`,
105
154
  // A card's control: one tier smaller where there is a cursor, the same
106
155
  // 44px where there is a thumb. See LISTING_CARD_ACTION_HIT.
107
- `${cardAction}{min-inline-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
108
- `min-block-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
156
+ `${cardAction}{${floor(LISTING_CARD_ACTION_HIT)};` +
109
157
  `display:inline-flex;align-items:center;justify-content:center}`,
110
- `@media ${phone}{${cardAction}{min-inline-size:${hit}px;min-block-size:${hit}px}}`,
158
+ `@media ${phone}{${cardAction}{${floor(LISTING_ACTION_HIT)}}}`,
111
159
  // The cluster. `align-items:flex-end` so a blocked heart's reason — the
112
160
  // one thing here that can be two lines — stacks against the same edge
113
161
  // instead of pushing the controls inwards (the arrangement