@stapel/listings-react 0.29.1 → 0.30.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 (64) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/dist/default/CardBadges.d.ts +32 -0
  3. package/dist/default/CardBadges.d.ts.map +1 -1
  4. package/dist/default/CardBadges.js +41 -3
  5. package/dist/default/CardBadges.js.map +1 -1
  6. package/dist/default/ListingDetailPane.d.ts +115 -8
  7. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  8. package/dist/default/ListingDetailPane.js +358 -177
  9. package/dist/default/ListingDetailPane.js.map +1 -1
  10. package/dist/default/ListingRelated.d.ts +88 -0
  11. package/dist/default/ListingRelated.d.ts.map +1 -0
  12. package/dist/default/ListingRelated.js +41 -0
  13. package/dist/default/ListingRelated.js.map +1 -0
  14. package/dist/default/ListingSpecList.d.ts +29 -34
  15. package/dist/default/ListingSpecList.d.ts.map +1 -1
  16. package/dist/default/ListingSpecList.js +67 -12
  17. package/dist/default/ListingSpecList.js.map +1 -1
  18. package/dist/default/condensedBar.d.ts +17 -0
  19. package/dist/default/condensedBar.d.ts.map +1 -0
  20. package/dist/default/condensedBar.js +73 -0
  21. package/dist/default/condensedBar.js.map +1 -0
  22. package/dist/default/detailGallery.d.ts +40 -40
  23. package/dist/default/detailGallery.d.ts.map +1 -1
  24. package/dist/default/detailGallery.js +129 -0
  25. package/dist/default/detailGallery.js.map +1 -1
  26. package/dist/default/detailRhythm.d.ts +15 -0
  27. package/dist/default/detailRhythm.d.ts.map +1 -0
  28. package/dist/default/detailRhythm.js +74 -0
  29. package/dist/default/detailRhythm.js.map +1 -0
  30. package/dist/default/icons.d.ts +13 -0
  31. package/dist/default/icons.d.ts.map +1 -1
  32. package/dist/default/icons.js +15 -0
  33. package/dist/default/icons.js.map +1 -1
  34. package/dist/default/index.d.ts +9 -5
  35. package/dist/default/index.d.ts.map +1 -1
  36. package/dist/default/index.js +13 -4
  37. package/dist/default/index.js.map +1 -1
  38. package/dist/i18n/es.d.ts.map +1 -1
  39. package/dist/i18n/es.js +9 -0
  40. package/dist/i18n/es.js.map +1 -1
  41. package/dist/i18n/keys.d.ts +23 -0
  42. package/dist/i18n/keys.d.ts.map +1 -1
  43. package/dist/i18n/keys.js +32 -0
  44. package/dist/i18n/keys.js.map +1 -1
  45. package/dist/i18n/ru.d.ts.map +1 -1
  46. package/dist/i18n/ru.js +9 -0
  47. package/dist/i18n/ru.js.map +1 -1
  48. package/llms.txt +3 -2
  49. package/manifest.json +28 -1
  50. package/nav-manifest.json +1 -1
  51. package/package.json +4 -4
  52. package/src/analytics/generated/events.json +1 -1
  53. package/src/default/CardBadges.tsx +65 -4
  54. package/src/default/ListingDetailPane.tsx +490 -52
  55. package/src/default/ListingRelated.tsx +154 -0
  56. package/src/default/ListingSpecList.tsx +64 -1
  57. package/src/default/condensedBar.ts +74 -0
  58. package/src/default/detailGallery.ts +141 -0
  59. package/src/default/detailRhythm.ts +78 -0
  60. package/src/default/icons.tsx +32 -0
  61. package/src/default/index.ts +45 -2
  62. package/src/i18n/es.ts +9 -0
  63. package/src/i18n/keys.ts +32 -0
  64. package/src/i18n/ru.ts +9 -0
@@ -75,8 +75,43 @@
75
75
  * `<h1>` through its published test id and a `MutationObserver`, for a
76
76
  * boolean the pane already knows. It is an `IntersectionObserver` on the
77
77
  * title, never a scroll listener.
78
+ *
79
+ * `actionsPlacement="condensed-top"` is the round after that: the pane draws
80
+ * the bar ITSELF — back, the title on one line, the travelling cluster — for
81
+ * the four things every container was writing identically around the render
82
+ * prop. See `condensedBar.ts`; the render prop is untouched and still wins.
83
+ *
84
+ * ── What else the reference has on this page, and now so does this ────────
85
+ *
86
+ * Three absences the closing-wave comparison measured against the reference
87
+ * classified, each closed as a SEAM rather than as a feature this pair
88
+ * invented data for:
89
+ *
90
+ * the strip did not say where it was
91
+ * `galleryLayout="strip"` is a native scroll container and the page had
92
+ * no position indicator at all (REPORT §20b). `useGalleryPosition` reads
93
+ * the strip's own scroll — never a tap, because the gesture that changes
94
+ * the photograph is not one.
95
+ *
96
+ * nothing on the page started a sentence
97
+ * The reference puts four canned questions above its contact control on
98
+ * every listing (§16). `quickQuestions` draws them and
99
+ * `onQuickQuestion` reports the press, because chat-react's door takes
100
+ * no initial message and a pane that claimed to prefill one would be
101
+ * inventing a seam the other pair does not have.
102
+ *
103
+ * the page ended
104
+ * `similar` / `fromSeller` (rows) and `renderSimilar` /
105
+ * `renderFromSeller` (slots) are the reference's two distinct "find
106
+ * more" mechanisms (§16 comparison 4). The rows come from the HOST's
107
+ * search, because this pair does not read search.
108
+ *
109
+ * And one that was NOT a defect in this component: the characteristics table
110
+ * draws every stored row it can key and counts the ones it cannot. Measured
111
+ * against a live answer, the shortfall the comparison saw was in the DATA —
112
+ * `characteristicsLimit` adds the reference's FOLD, not rows.
78
113
  */
79
- import { isValidElement, useCallback, useEffect, useRef } from "react";
114
+ import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
80
115
  import type { ReactElement, ReactNode } from "react";
81
116
  import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
82
117
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
@@ -94,7 +129,7 @@ import {
94
129
  useI18n,
95
130
  useT,
96
131
  } from "@stapel/core";
97
- import type { SignInCta } from "@stapel/core";
132
+ import type { LinkComponent, SignInCta } from "@stapel/core";
98
133
  import { cssVar, spacing } from "@stapel/tokens";
99
134
  import { isRedactedValue } from "@stapel/attributes-react";
100
135
  import { useListingDetail } from "../headless/ListingDetail.js";
@@ -109,18 +144,36 @@ import type { ListingActionsConfig } from "./ListingActions.js";
109
144
  import { LISTING_ACTION_CLASS } from "./actionRow.js";
110
145
  import {
111
146
  LISTINGS_GALLERY_CLASS,
147
+ LISTINGS_GALLERY_COUNTER_CLASS,
148
+ LISTINGS_GALLERY_FRAME_CLASS,
112
149
  LISTINGS_GALLERY_STYLE_HREF,
113
150
  detailGalleryCss,
151
+ useGalleryPosition,
114
152
  } from "./detailGallery.js";
115
153
  import type { ListingGalleryLayout } from "./detailGallery.js";
116
154
  import { useMovableCluster } from "./movableCluster.js";
155
+ import {
156
+ DETAIL_RHYTHM_CLASS,
157
+ DETAIL_RHYTHM_STYLE_HREF,
158
+ DETAIL_RULE_CLASS,
159
+ detailRhythmCss,
160
+ } from "./detailRhythm.js";
161
+ import {
162
+ CONDENSED_BAR_CLASS,
163
+ CONDENSED_BAR_STYLE_HREF,
164
+ CONDENSED_TITLE_CLASS,
165
+ condensedBarCss,
166
+ } from "./condensedBar.js";
167
+ import { ListingRelatedStrip } from "./ListingRelated.js";
168
+ import type { ListingRelatedContext } from "./ListingRelated.js";
117
169
  import { useNotice } from "./notice.js";
118
170
  import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
119
171
  import { SignInLink } from "./SignInLink.js";
120
- import { HeartIcon } from "./icons.js";
172
+ import { BackIcon, HeartIcon } from "./icons.js";
121
173
  import { ListingPhoto } from "./ListingPhoto.js";
122
174
  import { ListingPrice } from "./ListingPrice.js";
123
175
  import { ListingStatusBlock } from "./StatusTags.js";
176
+ import type { ListingCard as ListingCardData } from "../api/types.js";
124
177
  import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
125
178
 
126
179
  /** The reading measure of the page body. A detail page is prose plus a spec
@@ -304,13 +357,21 @@ export interface ListingDetailPaneProps
304
357
  * - `"bar"` — the condensed top bar the host draws through
305
358
  * {@link renderActionsBar}. Only meaningful in a LIST beside one of the
306
359
  * 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.
360
+ * the one cluster may travel to, never a home of its own;
361
+ * - `"condensed-top"` — the same second place, drawn by the PANE: a fixed
362
+ * strip carrying {@link onBack}, the title on one line and the cluster,
363
+ * on screen exactly while the title is off it. Also a loan and never a
364
+ * home. See `condensedBar.ts` for what it is and why the pane draws it
365
+ * rather than leaving every container to write the same four things.
308
366
  *
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.
367
+ * A LIST is how a host says "both": `["header", "condensed-top"]` keeps the
368
+ * cluster beside the title and lends it to the bar for as long as the bar is
369
+ * on screen. Exactly one home placement is honoured — the first entry that
370
+ * is neither loan — because two homes would need two instances, which is the
371
+ * defect this closes rather than the feature it adds.
372
+ *
373
+ * Asking for BOTH loans is a host that said something specific and a pair
374
+ * that has a default: `renderActionsBar` wins, and the pane draws nothing.
314
375
  */
315
376
  readonly actionsPlacement?:
316
377
  | ListingActionsPlacement
@@ -345,6 +406,94 @@ export interface ListingDetailPaneProps
345
406
  * pane's own single are all what this prop exists to end.
346
407
  */
347
408
  readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
409
+ /**
410
+ * LEAVE THIS PAGE — the back arrow of the pane's own condensed bar.
411
+ *
412
+ * A callback and not an `href`, for the reason the cards' open arm states:
413
+ * "back" is the HISTORY's, not an address, and a pair that called
414
+ * `history.back()` itself would move a router it does not own. Absent, the
415
+ * bar draws no arrow at all — a listing opened in a new tab has nowhere to
416
+ * go back to, and an arrow that reloads the home page is worse than none.
417
+ *
418
+ * Only read with `"condensed-top"` in {@link actionsPlacement}.
419
+ */
420
+ readonly onBack?: () => void;
421
+ /**
422
+ * THE FOUR QUESTIONS EVERY BUYER ASKS, as chips above the contact control.
423
+ *
424
+ * The reference classified puts a "ask the seller" block on every listing —
425
+ * four canned questions and a free-text box — and it is the one thing on
426
+ * that page that turns a reader into a conversation (§16, all four
427
+ * comparisons). This pair ships the four in its own three locales; a host
428
+ * with a category that wants different ones passes its own, and `[]`
429
+ * switches the block off.
430
+ *
431
+ * At most four are drawn. A fifth is not a longer row, it is a wrapped
432
+ * second row of chips above the primary action.
433
+ *
434
+ * Requires {@link onQuickQuestion} — see there for why the pair cannot wire
435
+ * the press itself.
436
+ */
437
+ readonly quickQuestions?: readonly string[];
438
+ /**
439
+ * WHAT A PRESSED CHIP DOES, and why it is the host's.
440
+ *
441
+ * The chip's whole job is to put its text in the composer of the thread with
442
+ * this seller, and neither half of that is this pair's: the thread belongs to
443
+ * `@stapel/chat-react` (it arrives here as `contactSlot`, because L2 pairs do
444
+ * not import each other), and as of chat-react 0.12.1 its own door takes no
445
+ * initial message — `<StartDirectChat>` takes `sellerId` and a subject,
446
+ * `useStartDirectChat` posts `{userId, subject}`, and `<MessageComposer>`
447
+ * opens on an empty string with no seed. So there is nothing for this pane to
448
+ * hand a text to.
449
+ *
450
+ * The honest seam is therefore the callback: the pane draws the chips and
451
+ * reports the press, the container opens the thread and seeds the composer
452
+ * the way it already navigates to one. Without this prop the block is not
453
+ * drawn — a chip that does nothing is worse than no chip.
454
+ */
455
+ readonly onQuickQuestion?: (text: string) => void;
456
+ /**
457
+ * MORE LIKE THIS ONE — rows the host fetched from search
458
+ * (`category=` plus the main axes), drawn as a strip under the description.
459
+ *
460
+ * Empty or absent: no section at all. See `<ListingRelatedStrip>` for why
461
+ * the rows arrive rather than being read here.
462
+ */
463
+ readonly similar?: readonly ListingCardData[];
464
+ /** More from the same seller — the `owner=` search, same contract as
465
+ * {@link similar}. */
466
+ readonly fromSeller?: readonly ListingCardData[];
467
+ /** Where the "show all" link of each strip goes — the search the strip is a
468
+ * sample of. Absent: the strip has no link. */
469
+ readonly similarHref?: string;
470
+ readonly fromSellerHref?: string;
471
+ /**
472
+ * The whole "more like this" section, built by the host — handed everything
473
+ * the pane knows about this listing. Overrides {@link similar}: a host with
474
+ * a renderer has already decided what the section is.
475
+ */
476
+ readonly renderSimilar?: (context: ListingRelatedContext) => ReactNode;
477
+ /** The same, for the seller's other listings. Overrides {@link fromSeller}. */
478
+ readonly renderFromSeller?: (context: ListingRelatedContext) => ReactNode;
479
+ /** Where one card of either strip leads. Absent, the cards are inert. */
480
+ readonly listingHref?: (id: number) => string;
481
+ /** The host's `<Link>`, for those cards and the two "show all" links. */
482
+ readonly linkComponent?: LinkComponent;
483
+ /**
484
+ * HOW MANY SPEC ROWS STAND BEFORE THE FOLD — handed to
485
+ * `<ListingSpecList limit>`, which is where the argument for a fold and the
486
+ * rule about when it is worth one are written.
487
+ *
488
+ * Default: no fold, which is every existing mount unchanged. `10` is the
489
+ * phone answer.
490
+ *
491
+ * It does NOT change which rows exist. A live read was measured against the
492
+ * reference here (§16 comparison 1) and the shortfall was in the DATA, not
493
+ * in this component: the pane draws every stored row the build can key, and
494
+ * says how many it could not (`listings-detail-unreadable`).
495
+ */
496
+ readonly characteristicsLimit?: number;
348
497
  /**
349
498
  * IS THE TITLE STILL IN THE FOLD?
350
499
  *
@@ -474,37 +623,57 @@ export type ListingActionsPlacement =
474
623
  | "header"
475
624
  | "gallery"
476
625
  | "buy-box"
477
- | "bar";
626
+ | "bar"
627
+ | "condensed-top";
478
628
 
479
- /** The cluster's HOME: the first entry that is not the borrowed bar. */
629
+ /** The two LOANS: places the one cluster may travel to, neither of them a
630
+ * home. See {@link ListingDetailPaneProps.actionsPlacement}. */
631
+ const LOANS: readonly ListingActionsPlacement[] = ["bar", "condensed-top"];
632
+
633
+ /** The cluster's HOME: the first entry that is not one of the two loans. */
480
634
  function homePlacement(
481
635
  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
636
+ ): Exclude<ListingActionsPlacement, "bar" | "condensed-top"> {
637
+ const all =
638
+ placement === undefined
639
+ ? []
640
+ : typeof placement === "string"
641
+ ? [placement]
642
+ : placement;
643
+ for (const one of all) {
644
+ // A loan alone names no home — the cluster still has to live somewhere
486
645
  // 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;
646
+ if (!LOANS.includes(one)) {
647
+ return one as Exclude<ListingActionsPlacement, "bar" | "condensed-top">;
648
+ }
491
649
  }
492
650
  return "header";
493
651
  }
494
652
 
495
- /** Did the host ask for the borrowed placement at all? */
496
- function wantsBar(
497
- placement: ListingDetailPaneProps["actionsPlacement"]
653
+ /** Did the host ask for this borrowed placement at all? */
654
+ function wants(
655
+ placement: ListingDetailPaneProps["actionsPlacement"],
656
+ one: ListingActionsPlacement
498
657
  ): boolean {
499
658
  if (placement === undefined) return false;
500
- if (typeof placement === "string") return placement === "bar";
501
- return placement.includes("bar");
659
+ if (typeof placement === "string") return placement === one;
660
+ return placement.includes(one);
502
661
  }
503
662
 
504
663
  /** Priorities for the two mount points: the bar wins while it is on screen. */
505
664
  const CLUSTER_HOME = 0;
506
665
  const CLUSTER_BAR = 1;
507
666
 
667
+ /**
668
+ * How many canned questions the page draws, whoever supplies them.
669
+ *
670
+ * Four is the reference's own count and it is not arbitrary: the chips stand
671
+ * between the price and the one primary action on the page, and a fifth does
672
+ * not lengthen the row on a 390px phone — it wraps, and pushes "message the
673
+ * seller" a line further down.
674
+ */
675
+ export const QUICK_QUESTIONS_MAX = 4;
676
+
508
677
  /**
509
678
  * The pane's own title, watched — see
510
679
  * {@link ListingDetailPaneProps.onTitleVisible}.
@@ -522,23 +691,55 @@ function useTitleVisibility(
522
691
  latest.current = onTitleVisible;
523
692
  });
524
693
  const wanted = onTitleVisible !== undefined;
694
+ /**
695
+ * THE ONE LIVE OBSERVER, held by the node it watches.
696
+ *
697
+ * React 19's ref cleanup is the normal way this is disconnected, and it is
698
+ * not the only caller: `<Typography.Title ref>` is antd's, which merges refs
699
+ * and invokes them itself — a callback's RETURN VALUE means nothing to a
700
+ * caller that is not React, so the cleanup was simply dropped and a fresh
701
+ * `IntersectionObserver` was attached to the same `<h1>` on every render of
702
+ * a page that re-renders on every query settle. Measured: three live
703
+ * observers on one heading after two crossings, each firing the host's
704
+ * callback again, so a subscriber counting crossings counted three where
705
+ * there were two.
706
+ *
707
+ * Holding the pair here makes the hook idempotent for the node it is already
708
+ * watching, whoever calls it and however many times, and disconnecting from
709
+ * an effect closes the case a dropped cleanup leaves open.
710
+ */
711
+ const held = useRef<{ node: Element; observer: IntersectionObserver } | null>(
712
+ null
713
+ );
714
+ const stop = useCallback((): void => {
715
+ held.current?.observer.disconnect();
716
+ held.current = null;
717
+ }, []);
718
+ // The belt, for the caller that discards the cleanup above.
719
+ useEffect(() => stop, [stop]);
525
720
  return useCallback(
526
721
  (node: HTMLElement | null): (() => void) | undefined => {
527
- if (node === null || !wanted) return undefined;
722
+ if (node === null || !wanted) {
723
+ stop();
724
+ return undefined;
725
+ }
528
726
  // No observer, no answer. A fabricated `true` would wedge a host's bar
529
727
  // open on an arm that has no scrolling to close it with.
530
728
  if (typeof IntersectionObserver === "undefined") return undefined;
729
+ // Already watching this very element: a second observer on it would
730
+ // report every crossing twice.
731
+ if (held.current?.node === node) return stop;
732
+ stop();
531
733
  const observer = new IntersectionObserver((entries) => {
532
734
  const entry = entries[entries.length - 1];
533
735
  if (entry === undefined) return;
534
736
  latest.current?.(entry.isIntersecting);
535
737
  });
536
738
  observer.observe(node);
537
- return () => {
538
- observer.disconnect();
539
- };
739
+ held.current = { node, observer };
740
+ return stop;
540
741
  },
541
- [wanted]
742
+ [wanted, stop]
542
743
  );
543
744
  }
544
745
 
@@ -587,10 +788,53 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
587
788
  rendered inline where it always was, no portal and no slot divs. */
588
789
  const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
589
790
  const renderBar = props.renderActionsBar;
590
- const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
791
+ /* TWO LOANS, ONE WINNER. The host's own render prop is the specific answer
792
+ and the pane's condensed bar is the default one, so a page that asked for
793
+ both gets the host's — and never two bars competing for the top of one
794
+ viewport, each holding half a cluster. */
795
+ const hostBar = wants(props.actionsPlacement, "bar") && renderBar !== undefined;
796
+ const ownBar = !hostBar && wants(props.actionsPlacement, "condensed-top");
797
+ const barred = hostBar || ownBar;
591
798
  const movable = useMovableCluster(barred);
592
799
  const moving = barred && movable.portable;
593
- const titleRef = useTitleVisibility(props.onTitleVisible);
800
+ /* IS THE TITLE ON SCREEN — the pane's own copy of the answer it already
801
+ publishes. `false` to start, because the page opens AT the title and a bar
802
+ that flashed on the first frame is the defect the observer exists to
803
+ avoid. */
804
+ const [titleGone, setTitleGone] = useState(false);
805
+ const onTitleVisible = props.onTitleVisible;
806
+ /* One observer for both readers. The hook holds the latest callback in a
807
+ ref, so this inline arrow costs no re-observation — and where the host
808
+ asked for neither, `undefined` keeps the whole thing unarmed. */
809
+ const titleRef = useTitleVisibility(
810
+ onTitleVisible === undefined && !ownBar
811
+ ? undefined
812
+ : (visible: boolean): void => {
813
+ // Only the arm that draws a bar keeps state. A host that merely
814
+ // subscribed does not get a re-render of this page per crossing.
815
+ if (ownBar) setTitleGone(!visible);
816
+ onTitleVisible?.(visible);
817
+ }
818
+ );
819
+ /* WHERE THE STRIP IS. Armed only for the arm that scrolls and only for a
820
+ listing with something to scroll THROUGH — see `useGalleryPosition`. */
821
+ const strip = galleryLayout === "strip" && bag.images.length > 1;
822
+ const photo = useGalleryPosition(strip);
823
+ /* THE FOUR QUESTIONS. Defaults from this pair's own catalogue, a host's own
824
+ list when it has one, and NOTHING at all when nobody can act on a press —
825
+ see `onQuickQuestion`. Capped at four: a fifth is a second row of chips
826
+ standing between a reader and the one primary action on the page. */
827
+ const quickQuestions: readonly string[] =
828
+ props.onQuickQuestion === undefined
829
+ ? []
830
+ : (
831
+ props.quickQuestions ?? [
832
+ t(LISTINGS_I18N_KEYS.detailQuestionAvailable),
833
+ t(LISTINGS_I18N_KEYS.detailQuestionPrice),
834
+ t(LISTINGS_I18N_KEYS.detailQuestionViewing),
835
+ t(LISTINGS_I18N_KEYS.detailQuestionDelivery),
836
+ ]
837
+ ).slice(0, QUICK_QUESTIONS_MAX);
594
838
  // The two arms of `actions` — see `isActionsConfig`.
595
839
  const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
596
840
  props.actions
@@ -650,7 +894,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
650
894
  }}
651
895
  {...(props.mode !== undefined ? { mode: props.mode } : {})}
652
896
  >
653
- <Flex vertical gap={spacing[4]} data-testid="listings-detail">
897
+ {/* THE PAGE'S RHYTHM IS THE COLUMN'S — see `detailRhythm.ts`. Half the
898
+ children here are antd components carrying outer margins of their
899
+ own, and a margin and a gap ADD: the stand measured 111, 53, 42, 29
900
+ and 27 pixels inside a column that declares 16. */}
901
+ <style href={DETAIL_RHYTHM_STYLE_HREF} precedence="default">
902
+ {detailRhythmCss()}
903
+ </style>
904
+ <Flex
905
+ vertical
906
+ gap={spacing[4]}
907
+ className={DETAIL_RHYTHM_CLASS}
908
+ data-testid="listings-detail"
909
+ >
654
910
  {bag.removed ? (
655
911
  <ErrorAlert
656
912
  testId="listings-detail-removed"
@@ -844,25 +1100,74 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
844
1100
  portal has no position of its own (its content is wherever the
845
1101
  winning slot is), and a bar is `position: fixed` chrome whose
846
1102
  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
- </>
1103
+ /* THE PANE'S OWN CONDENSED BAR (`"condensed-top"`).
1104
+
1105
+ Mounted only while the title is off screen, which is what makes
1106
+ the cluster travel at all: the slot inside it is the higher
1107
+ priority, so appearing borrows the cluster and disappearing
1108
+ hands it straight back to the heading the same DOM node, the
1109
+ same optimistic favourite, no second `useFavoriteToggle`.
1110
+
1111
+ The back arrow is absent when the host gave no `onBack`: a
1112
+ listing opened in a new tab has nothing to go back to. */
1113
+ const condensedBar =
1114
+ !moving || !ownBar || !titleGone ? null : (
1115
+ <div
1116
+ className={CONDENSED_BAR_CLASS}
1117
+ data-testid="listings-detail-condensed-bar"
1118
+ >
1119
+ <style href={CONDENSED_BAR_STYLE_HREF} precedence="default">
1120
+ {condensedBarCss()}
1121
+ </style>
1122
+ {props.onBack === undefined ? null : (
1123
+ <Button
1124
+ type="text"
1125
+ shape="circle"
1126
+ className={LISTING_ACTION_CLASS}
1127
+ aria-label={t(LISTINGS_I18N_KEYS.detailBack)}
1128
+ icon={<BackIcon />}
1129
+ data-testid="listings-detail-back"
1130
+ data-analytics="none"
1131
+ data-analytics-reason="navigation — the host owns its own history"
1132
+ onClick={props.onBack}
1133
+ />
1134
+ )}
1135
+ {/* NOT a heading: the page already has exactly one, and a
1136
+ second copy of the same words at a heading level would
1137
+ put the listing into the document outline twice. */}
1138
+ <span
1139
+ className={CONDENSED_TITLE_CLASS}
1140
+ data-testid="listings-detail-condensed-title"
1141
+ >
1142
+ {listing.title ?? ""}
1143
+ </span>
1144
+ {movable.slot(CLUSTER_BAR, "condensed-top")}
1145
+ </div>
859
1146
  );
860
1147
 
1148
+ const clusterLayer = !moving ? null : (
1149
+ <>
1150
+ {movable.render(readerActions)}
1151
+ {/* NOT a slot with a silent absence: one of the two arms is
1152
+ the only thing that makes `moving` true, so this is
1153
+ unreachable without one, and an absent bar (the host's own
1154
+ `null`, or the title back on screen) is the answer that
1155
+ sends the cluster home. There is no hole to place a
1156
+ `<SlotPlaceholder>` in — the cluster is at its primary
1157
+ placement instead. */}
1158
+ {hostBar && renderBar !== undefined
1159
+ ? renderBar(movable.slot(CLUSTER_BAR, "bar"))
1160
+ : condensedBar}
1161
+ </>
1162
+ );
1163
+
861
1164
  /* Element-width tiles: the grid decides how many fit, the
862
1165
  photos fill them. */
863
- const gallery = (
1166
+ const galleryBox = (
864
1167
  <div
1168
+ ref={photo.ref}
865
1169
  data-testid="listings-detail-gallery"
1170
+ data-gallery-active={String(photo.active)}
866
1171
  className={LISTINGS_GALLERY_CLASS}
867
1172
  // The layout is a CLASS and an attribute, not an inline
868
1173
  // `display`: a host with a shape neither arm offers can then
@@ -903,6 +1208,36 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
903
1208
  </div>
904
1209
  );
905
1210
 
1211
+ /* "3 of 16" OVER THE STRIP, and it moves with the strip.
1212
+ `aria-live="polite"` because it changes with no gesture a screen
1213
+ reader would otherwise report — a finger scrolls the strip
1214
+ natively and this line is the only announcement of the move.
1215
+ The grid arm gets none of it: every photograph is on screen at
1216
+ once there, and "1 of 3" over a grid of three is a control
1217
+ panel for a picture that needs none.
1218
+
1219
+ It is a SIBLING of the strip inside a frame, never a child: in
1220
+ this arm the strip is the scroll container, and an absolutely
1221
+ positioned child of a scroller scrolls away with the content it
1222
+ is supposed to be counting. See `detailGallery.ts`. */
1223
+ const gallery = !strip ? (
1224
+ galleryBox
1225
+ ) : (
1226
+ <div className={LISTINGS_GALLERY_FRAME_CLASS}>
1227
+ {galleryBox}
1228
+ <span
1229
+ className={LISTINGS_GALLERY_COUNTER_CLASS}
1230
+ data-testid="listings-detail-photo-counter"
1231
+ aria-live="polite"
1232
+ >
1233
+ {t(LISTINGS_I18N_KEYS.cardPhotoCounter, {
1234
+ index: photo.active + 1,
1235
+ total: bag.images.length,
1236
+ })}
1237
+ </span>
1238
+ </div>
1239
+ );
1240
+
906
1241
  const heading = (
907
1242
  <>
908
1243
  {/* THE TITLE AND THE TWO ACTIONS SHARE A LINE (§23).
@@ -1029,9 +1364,42 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1029
1364
  </GatedButton>
1030
1365
  </>
1031
1366
  ) : (
1032
- <div data-testid="listings-detail-contact">
1033
- {props.contactSlot ?? <SlotPlaceholder name="contactSlot" />}
1034
- </div>
1367
+ <Flex vertical gap={spacing[2]} style={{ minWidth: 0 }}>
1368
+ {/* ASK THE SELLER — above the door into the conversation,
1369
+ because that is what a pressed chip opens. Never on the
1370
+ owner's own page: the owner is the person being asked.
1371
+
1372
+ See `onQuickQuestion` for why the press leaves this
1373
+ pair: chat-react's door takes no initial message, so a
1374
+ pane that "prefilled" one would be inventing a seam
1375
+ that does not exist on the other side. */}
1376
+ {quickQuestions.length === 0 ? null : (
1377
+ <Flex vertical gap={spacing[1]} data-testid="listings-detail-questions">
1378
+ <Typography.Text type="secondary">
1379
+ {t(LISTINGS_I18N_KEYS.detailAskSeller)}
1380
+ </Typography.Text>
1381
+ <Flex wrap gap={spacing[2]}>
1382
+ {quickQuestions.map((question) => (
1383
+ <Button
1384
+ key={question}
1385
+ size="small"
1386
+ data-testid="listings-detail-question"
1387
+ data-analytics="none"
1388
+ data-analytics-reason="business action — host app wraps with its own tracked()"
1389
+ onClick={() => {
1390
+ props.onQuickQuestion?.(question);
1391
+ }}
1392
+ >
1393
+ {question}
1394
+ </Button>
1395
+ ))}
1396
+ </Flex>
1397
+ </Flex>
1398
+ )}
1399
+ <div data-testid="listings-detail-contact">
1400
+ {props.contactSlot ?? <SlotPlaceholder name="contactSlot" />}
1401
+ </div>
1402
+ </Flex>
1035
1403
  )}
1036
1404
 
1037
1405
  {/* The reader's two actions live in the cluster now (see
@@ -1100,7 +1468,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1100
1468
  ) : split ? (
1101
1469
  <ListingSpecColumns features={specFeatures} values={specValues} />
1102
1470
  ) : (
1103
- <ListingSpecList features={specFeatures} values={specValues} />
1471
+ <ListingSpecList
1472
+ features={specFeatures}
1473
+ values={specValues}
1474
+ {...(props.characteristicsLimit !== undefined
1475
+ ? { limit: props.characteristicsLimit }
1476
+ : {})}
1477
+ />
1104
1478
  );
1105
1479
 
1106
1480
  const specsSection = (
@@ -1162,6 +1536,62 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1162
1536
  <div data-testid="listings-detail-aside">{props.aside}</div>
1163
1537
  ) : null;
1164
1538
 
1539
+ /* THE TWO "FIND MORE" SECTIONS — see `<ListingRelatedStrip>` for
1540
+ why the rows arrive from the host and are not read here.
1541
+
1542
+ The render prop wins over the rows for each strip separately: a
1543
+ host may have a catalogue-link widget for "similar" and a plain
1544
+ list of the seller's other listings, which is exactly the pair
1545
+ the reference draws. Neither is rendered empty. */
1546
+ const related: ListingRelatedContext = {
1547
+ listingId: props.id,
1548
+ ...(listing.category_id !== undefined && listing.category_id !== null
1549
+ ? { categoryId: listing.category_id }
1550
+ : { categoryId: undefined }),
1551
+ ownerKey: listing.owner,
1552
+ axes: bag.titleFeatures,
1553
+ };
1554
+ const strips = (
1555
+ <>
1556
+ {props.renderSimilar !== undefined ? (
1557
+ props.renderSimilar(related)
1558
+ ) : (
1559
+ <ListingRelatedStrip
1560
+ heading={t(LISTINGS_I18N_KEYS.detailSimilar)}
1561
+ items={props.similar ?? []}
1562
+ testId="listings-detail-similar"
1563
+ {...(props.similarHref !== undefined
1564
+ ? { showAllHref: props.similarHref }
1565
+ : {})}
1566
+ {...(props.listingHref !== undefined
1567
+ ? { listingHref: props.listingHref }
1568
+ : {})}
1569
+ {...(props.linkComponent !== undefined
1570
+ ? { linkComponent: props.linkComponent }
1571
+ : {})}
1572
+ />
1573
+ )}
1574
+ {props.renderFromSeller !== undefined ? (
1575
+ props.renderFromSeller(related)
1576
+ ) : (
1577
+ <ListingRelatedStrip
1578
+ heading={t(LISTINGS_I18N_KEYS.detailFromSeller)}
1579
+ items={props.fromSeller ?? []}
1580
+ testId="listings-detail-from-seller"
1581
+ {...(props.fromSellerHref !== undefined
1582
+ ? { showAllHref: props.fromSellerHref }
1583
+ : {})}
1584
+ {...(props.listingHref !== undefined
1585
+ ? { listingHref: props.listingHref }
1586
+ : {})}
1587
+ {...(props.linkComponent !== undefined
1588
+ ? { linkComponent: props.linkComponent }
1589
+ : {})}
1590
+ />
1591
+ )}
1592
+ </>
1593
+ );
1594
+
1165
1595
  if (!split) {
1166
1596
  // The single column. `"end"` is the order it has always read —
1167
1597
  // the host's aside joins where the footer's flow already is;
@@ -1178,10 +1608,11 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1178
1608
  {buyBox}
1179
1609
  {actionError}
1180
1610
  {asideAfterActions ? aside : null}
1181
- <Divider />
1611
+ <Divider className={DETAIL_RULE_CLASS} />
1182
1612
  {description}
1183
1613
  {specsSection}
1184
1614
  {meta}
1615
+ {strips}
1185
1616
  {asideAfterActions ? null : aside}
1186
1617
  {props.footer}
1187
1618
  {clusterLayer}
@@ -1205,13 +1636,19 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1205
1636
  alignItems: "start",
1206
1637
  }}
1207
1638
  >
1208
- <Flex vertical gap={spacing[4]}>
1639
+ <Flex
1640
+ vertical
1641
+ gap={spacing[4]}
1642
+ className={DETAIL_RHYTHM_CLASS}
1643
+ data-testid="listings-detail-reading-column"
1644
+ >
1209
1645
  {gallery}
1210
1646
  {heading}
1211
- <Divider />
1647
+ <Divider className={DETAIL_RULE_CLASS} />
1212
1648
  {description}
1213
1649
  {specsSection}
1214
1650
  {meta}
1651
+ {strips}
1215
1652
  {props.footer}
1216
1653
  </Flex>
1217
1654
  {/* Sticky, so the actions ride along a page whose left
@@ -1222,6 +1659,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
1222
1659
  <Flex
1223
1660
  vertical
1224
1661
  gap={spacing[3]}
1662
+ className={DETAIL_RHYTHM_CLASS}
1225
1663
  data-testid="listings-detail-buy-column"
1226
1664
  style={{
1227
1665
  position: "sticky",