@stapel/listings-react 0.13.0 → 0.14.1

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.
@@ -33,7 +33,10 @@
33
33
  * that wrapped its own composition in `<ListingsSkinTheme>` imports
34
34
  * `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
35
35
  */
36
- export { ListingCard } from "./ListingCard.js";
36
+ export { ListingCard,
37
+ // The card's row arm, for a container that wants to lay out against the same
38
+ // threshold (and for a test that measures it). See `<ListingCard>`'s header.
39
+ LISTING_CARD_ROW_MIN, LISTING_CARD_ROW_MEDIA, } from "./ListingCard.js";
37
40
  export { ListingSerpCard } from "./ListingSerpCard.js";
38
41
  export { ListingFeedCard } from "./ListingFeedCard.js";
39
42
  export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
@@ -42,6 +45,6 @@ export { ListingComposerPage, COMPOSER_DETAILS_PLACEMENT, COMPOSER_MEASURE, comp
42
45
  export { MyListingsPane } from "./MyListingsPane.js";
43
46
  export { FavoritesPane, FAVORITES_CARD_MIN } from "./FavoritesPane.js";
44
47
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
45
- export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
48
+ export { ListingPhoto, ListingPhotoStrip, LISTING_PHOTO_ASPECT, } from "./ListingPhoto.js";
46
49
  export { SignInLink } from "./SignInLink.js";
47
50
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/default/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAO/C,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAMvD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAKvD,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE5D,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE7F,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,gBAAgB,EAChB,eAAe,GAChB,MAAM,0BAA0B,CAAC;AASlC,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAMvE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEnF,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEvE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/default/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EACL,WAAW;AACX,6EAA6E;AAC7E,6EAA6E;AAC7E,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAO1B,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAMvD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAKvD,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE5D,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE7F,OAAO,EACL,mBAAmB,EACnB,0BAA0B,EAC1B,gBAAgB,EAChB,eAAe,GAChB,MAAM,0BAA0B,CAAC;AASlC,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAMvE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEnF,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @stapel/listings-react 0.13.0
1
+ # @stapel/listings-react 0.14.1
2
2
 
3
3
  Headless React flow pair for stapel-listings (contract >=0.10 <0.11) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
@@ -106,14 +106,15 @@ const { tracked } = useTracked();
106
106
  ```
107
107
 
108
108
  ## Demos (defineDemo → manifest.demos; compiled, linted, rendered examples)
109
- - listings.card → <ListingCard> [default|saved|visitor|no-photo] demo/ListingCard.demo.tsx
109
+ - listings.card → <ListingCard> [default|saved|visitor|grid cell|list row|no-photo] demo/ListingCard.demo.tsx
110
110
  - listings.composer → <ListingComposerPage> [default|unwired|photos-in-flight|unsupported-type] demo/ListingComposer.demo.tsx
111
111
  - listings.detail → <ListingDetailPane> [default|contact-unwired|owner|not-found] demo/ListingDetail.demo.tsx
112
112
  - listings.favorites → <FavoritesPane> [default|paged|empty|failed|visitor] demo/Favorites.demo.tsx
113
113
  - listings.feed-card → <ListingFeedCard> [default|tile|visitor|wide] demo/ListingFeedCard.demo.tsx
114
114
  - listings.mine → <MyListingsPane> [default|no-editor|taken-down|visitor|failed] demo/MyListings.demo.tsx
115
115
  - listings.parts → <ListingStatusBlock> [default|tags|notes|media|door] demo/ListingParts.demo.tsx
116
- - listings.serp-card → <ListingSerpCard> [default|trend|visitor|bare] demo/ListingSerpCard.demo.tsx
116
+ - listings.photo-strip → <ListingPhotoStrip> [default|single|no photos] demo/ListingPhotoStrip.demo.tsx
117
+ - listings.serp-card → <ListingSerpCard> [default|trend|visitor|desktop row|bare] demo/ListingSerpCard.demo.tsx
117
118
  Each source file is the canonical usage snippet (open the default variant).
118
119
 
119
120
  ## Snippets
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.13.0",
4
+ "version": "0.14.1",
5
5
  "backend": {
6
6
  "module": "stapel-listings",
7
7
  "contract": ">=0.10 <0.11"
@@ -669,6 +669,8 @@
669
669
  "default",
670
670
  "saved",
671
671
  "visitor",
672
+ "grid cell",
673
+ "list row",
672
674
  "no-photo"
673
675
  ],
674
676
  "source": "demo/ListingCard.demo.tsx"
@@ -795,6 +797,22 @@
795
797
  ],
796
798
  "source": "demo/ListingParts.demo.tsx"
797
799
  },
800
+ {
801
+ "id": "listings.photo-strip",
802
+ "title": "Listing photo strip",
803
+ "description": "Every stored photo of a listing as one swipeable, snapping strip — the gallery <ListingCard> and <ListingSerpCard> both render, always as a SIBLING of the card's anchor. A swipeable strip is a control, a link may not contain one, and a horizontal swipe that ends inside an <a> is a swipe the browser may deliver as a click; that is what made the desktop gallery unreachable on a live deployment while the phone one worked.",
804
+ "component": "ListingPhotoStrip",
805
+ "tokens": [
806
+ "surface-sunken",
807
+ "text-muted"
808
+ ],
809
+ "variants": [
810
+ "default",
811
+ "single",
812
+ "no photos"
813
+ ],
814
+ "source": "demo/ListingPhotoStrip.demo.tsx"
815
+ },
798
816
  {
799
817
  "id": "listings.serp-card",
800
818
  "title": "Listing SERP card",
@@ -811,6 +829,7 @@
811
829
  "default",
812
830
  "trend",
813
831
  "visitor",
832
+ "desktop row",
814
833
  "bare"
815
834
  ],
816
835
  "source": "demo/ListingSerpCard.demo.tsx"
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/listings-react",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "entries": [
5
5
  {
6
6
  "id": "listings.detail",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/listings-react",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -102,7 +102,7 @@
102
102
  "size-limit": "^11.2.0",
103
103
  "typescript": "^5.8.3",
104
104
  "vitest": "^3.2.4",
105
- "@stapel/attributes-react": "^0.7.0",
105
+ "@stapel/attributes-react": "^0.8.1",
106
106
  "@stapel/core": "^0.22.0",
107
107
  "@stapel/currencies-react": "^0.3.0",
108
108
  "@stapel/image": "^0.4.2",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.13.0",
4
+ "version": "0.14.1",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -16,9 +16,9 @@
16
16
  * Nothing on a classified works that way: the card IS the target, and the only
17
17
  * separate control on it is the favourite heart.
18
18
  *
19
- * So the photo, the price, the title, the badges and the location now live
20
- * INSIDE one anchor that covers the whole card, and `listings.card.open` is
21
- * retired rather than left orphaned in three catalogues.
19
+ * So the price, the title, the badges and the location live INSIDE one anchor
20
+ * that covers the whole card, and `listings.card.open` is retired rather than
21
+ * left orphaned in three catalogues.
22
22
  *
23
23
  * What that must not cost is the anchor semantics won earlier: this is a real
24
24
  * `<a href>`, so middle-click opens a tab, ⌘-click opens a tab, "copy link
@@ -44,6 +44,29 @@
44
44
  * A listing with no title falls back to `listings.card.untitled`, because a
45
45
  * link announced as nothing is worse than one announced as untitled.
46
46
  *
47
+ * ── The photos moved OUT of the anchor, and the card became a ROW ─────────
48
+ *
49
+ * Both from the same measurement of the live desktop SERP, in list view:
50
+ * **one card per screen** — 974×835, of which the photograph was 974×731 —
51
+ * showing a SINGLE photo, with no carousel, and that photo inside the anchor.
52
+ * The phone card beside it was correct on every count.
53
+ *
54
+ * The photo used to be inside the anchor on the argument that a still `<img>`
55
+ * in a link is just a bigger link, which is true and stops being true the
56
+ * moment there is more than one photo: a swipeable strip is a control, a link
57
+ * may not contain one, and a horizontal swipe that ends inside an `<a>` is a
58
+ * swipe the browser may deliver as a click. So the strip is a SIBLING of the
59
+ * anchor — the arrangement `<ListingSerpCard>` has always used, and the reason
60
+ * the phone gallery works — and the anchor still covers everything a person
61
+ * READS. `<ListingPhotoStrip>` is the one gallery both cards draw.
62
+ *
63
+ * The row is the other half. A grid card handed a full-page-wide track keeps
64
+ * its shape and becomes a banner; this card now asks its OWN width
65
+ * (`@container`, see {@link LISTING_CARD_ROW_MIN}) and lays the photo beside
66
+ * the text above 560px. In a grid its column is never that wide, so nothing
67
+ * about the grid changes; in a list several rows fit a screen, which is the
68
+ * only reason a list exists.
69
+ *
47
70
  * ── Why the heart is a row under the card and not floating on the photo ────
48
71
  *
49
72
  * Because for a signed-out visitor — which is most of the traffic a storefront
@@ -88,7 +111,7 @@ import { useFavoriteToggle } from "../headless/Favorites.js";
88
111
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
89
112
  import { HeartIcon } from "./icons.js";
90
113
  import { SignInLink } from "./SignInLink.js";
91
- import { ListingPhoto } from "./ListingPhoto.js";
114
+ import { ListingPhotoStrip } from "./ListingPhoto.js";
92
115
  import { ListingPrice } from "./ListingPrice.js";
93
116
  import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
94
117
 
@@ -151,10 +174,45 @@ export type ListingCardBlockedReason = "text" | "line";
151
174
 
152
175
  /** The class the whole-card target carries, for {@link cardTargetCss}. */
153
176
  export const CARD_TARGET_CLASS = "stapel-listing-card-target";
177
+ /** The class the card's OUTER box carries — the size container the row layout
178
+ * is asked about. A container cannot answer a query about itself, which is why
179
+ * this is a wrapper and not the frame. */
180
+ export const CARD_QUERY_CLASS = "stapel-listing-card-q";
181
+ /** The class the media/reading frame carries. */
182
+ export const CARD_FRAME_CLASS = "stapel-listing-card-frame";
183
+ /** The class the photo strip's box carries. */
184
+ export const CARD_MEDIA_CLASS = "stapel-listing-card-media";
185
+ /** The class the reading column carries. */
186
+ export const CARD_MAIN_CLASS = "stapel-listing-card-main";
187
+ /** Added to the frame by a card whose media is FULL-BLEED when stacked
188
+ * (`<ListingCard>`), so the row arm gets the inset the stacked arm does not. */
189
+ export const CARD_BLEED_CLASS = "stapel-listing-card-bleed";
154
190
 
155
191
  /** The `href` the hoisted card stylesheet is deduplicated by. */
156
192
  export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
157
193
 
194
+ /**
195
+ * The card's own inline size above which it stops being a card and becomes a
196
+ * ROW.
197
+ *
198
+ * Measured on the live desktop SERP in "list" view: **one card per screen**,
199
+ * 974×835, of which the photograph was 974×731 — a grid card handed a
200
+ * full-page track and asked to keep its shape. A shopper comparing offers got
201
+ * one offer per scroll.
202
+ *
203
+ * 560px is the width at which a 4:3 photo stops being a banner: below it a
204
+ * two-column row leaves the text nothing, above it the photo is a thumbnail
205
+ * beside a paragraph. A `@container` query rather than a media query because
206
+ * the card does not know the viewport and must not care: the same card is a
207
+ * grid cell 300px wide on a 1440px screen and a full-width row on a 700px one,
208
+ * and only its OWN width decides which it is.
209
+ */
210
+ export const LISTING_CARD_ROW_MIN = 560;
211
+
212
+ /** The photo's width in the row arm. Wide enough to read the goods, narrow
213
+ * enough that several rows fit a screen — the whole point of a list. */
214
+ export const LISTING_CARD_ROW_MEDIA = 260;
215
+
158
216
  /**
159
217
  * The one rule an inline style cannot express: `:focus-visible`.
160
218
  *
@@ -168,9 +226,33 @@ export const CARD_TARGET_STYLE_HREF = "stapel-listings-card-target";
168
226
  * design system's ROLE catalogue and this is a component's private plumbing.
169
227
  */
170
228
  export function cardTargetCss(): string {
229
+ const q = `.${CARD_QUERY_CLASS}`;
230
+ const frame = `.${CARD_FRAME_CLASS}`;
231
+ const media = `.${CARD_MEDIA_CLASS}`;
232
+ const main = `.${CARD_MAIN_CLASS}`;
233
+ const bleed = `.${CARD_BLEED_CLASS}`;
171
234
  return [
172
235
  `.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
173
236
  `.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
237
+ // The card asks about its OWN width, not the window's — see
238
+ // `LISTING_CARD_ROW_MIN`.
239
+ `${q}{container-type:inline-size}`,
240
+ `${frame}{display:flex;flex-direction:column;min-inline-size:0}`,
241
+ `${media}{min-inline-size:0}`,
242
+ `${main}{display:flex;flex-direction:column;flex:1 1 auto;min-inline-size:0}`,
243
+ // The row arm. `align-items:flex-start` so a short text column does not
244
+ // stretch the photo, and a fixed media basis so the picture cannot grow
245
+ // into the 974px banner the live SERP was measured at.
246
+ `@container (min-width:${String(LISTING_CARD_ROW_MIN)}px){` +
247
+ `${frame}{flex-direction:row;align-items:flex-start}` +
248
+ `${media}{flex:0 0 ${String(LISTING_CARD_ROW_MEDIA)}px;` +
249
+ `max-inline-size:${String(LISTING_CARD_ROW_MEDIA)}px}` +
250
+ // A full-bleed stacked card has no padding of its own to give the row
251
+ // arm, so the row arm states its own; the reading column's padding is
252
+ // what separates the two.
253
+ `${bleed}{padding-block:var(--listing-card-inset);` +
254
+ `padding-inline-start:var(--listing-card-inset)}` +
255
+ `}`,
174
256
  ].join("");
175
257
  }
176
258
 
@@ -343,8 +425,9 @@ export function ListingCard(props: ListingCardProps): ReactElement {
343
425
  title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
344
426
 
345
427
  /**
346
- * Everything a person reads on the card, in the order a classified reads it:
347
- * photo, price, title, the seller's own spec line, the badges, the place.
428
+ * Everything a person READS on the card, in the order a classified reads it:
429
+ * price, title, the seller's own spec line, the badges, the place. The
430
+ * photos are not here — they are the strip beside this, outside the anchor.
348
431
  *
349
432
  * The search projection carries `title`, `price`, `currency`,
350
433
  * `location_label`, `image` and `published_at` and NO feature badges — so
@@ -353,57 +436,51 @@ export function ListingCard(props: ListingCardProps): ReactElement {
353
436
  * they are the two fields a result always has.
354
437
  */
355
438
  const content = (
356
- <>
357
- <ListingPhoto
358
- imageRef={listing.images?.[0]}
359
- alt={title.length > 0 ? title : String(listing.id)}
360
- />
361
- <Flex
362
- vertical
363
- gap={spacing[1]}
364
- style={{ minWidth: 0, padding: token.paddingSM }}
365
- >
366
- {props.badge}
367
-
368
- <Typography.Text strong data-testid="listings-card-price">
369
- <ListingPrice
370
- amount={listing.price}
371
- {...(listing.currency !== undefined ? { currency: listing.currency } : {})}
372
- />
373
- </Typography.Text>
439
+ <Flex
440
+ vertical
441
+ gap={spacing[1]}
442
+ style={{ minWidth: 0, padding: token.paddingSM }}
443
+ >
444
+ {props.badge}
374
445
 
375
- <Typography.Text ellipsis data-testid="listings-card-title">
376
- {title}
377
- </Typography.Text>
446
+ <Typography.Text strong data-testid="listings-card-price">
447
+ <ListingPrice
448
+ amount={listing.price}
449
+ {...(listing.currency !== undefined ? { currency: listing.currency } : {})}
450
+ />
451
+ </Typography.Text>
378
452
 
379
- {/* The title features are a stored projection too — the seller's
380
- "1.5 TB, black" line, already ordered by the server. */}
381
- {titleDaos.length > 0 ? (
382
- <Typography.Text type="secondary" ellipsis>
383
- <FeatureBadges
384
- features={featuresFromDaoList(titleDaos, copy).map(
385
- (view) => view.feature
386
- )}
387
- values={featuresDtoFromDaoList(titleDaos)}
388
- />
389
- </Typography.Text>
390
- ) : null}
453
+ <Typography.Text ellipsis data-testid="listings-card-title">
454
+ {title}
455
+ </Typography.Text>
391
456
 
392
- {badgeFeatures.length > 0 ? (
457
+ {/* The title features are a stored projection too — the seller's
458
+ "1.5 TB, black" line, already ordered by the server. */}
459
+ {titleDaos.length > 0 ? (
460
+ <Typography.Text type="secondary" ellipsis>
393
461
  <FeatureBadges
394
- features={badgeFeatures.map((view) => view.feature)}
395
- values={badgeValues}
462
+ features={featuresFromDaoList(titleDaos, copy).map(
463
+ (view) => view.feature
464
+ )}
465
+ values={featuresDtoFromDaoList(titleDaos)}
396
466
  />
397
- ) : null}
467
+ </Typography.Text>
468
+ ) : null}
469
+
470
+ {badgeFeatures.length > 0 ? (
471
+ <FeatureBadges
472
+ features={badgeFeatures.map((view) => view.feature)}
473
+ values={badgeValues}
474
+ />
475
+ ) : null}
398
476
 
399
- {listing.location_label !== undefined &&
400
- listing.location_label.length > 0 ? (
401
- <Typography.Text type="secondary" data-testid="listings-card-location">
402
- {listing.location_label}
403
- </Typography.Text>
404
- ) : null}
405
- </Flex>
406
- </>
477
+ {listing.location_label !== undefined &&
478
+ listing.location_label.length > 0 ? (
479
+ <Typography.Text type="secondary" data-testid="listings-card-location">
480
+ {listing.location_label}
481
+ </Typography.Text>
482
+ ) : null}
483
+ </Flex>
407
484
  );
408
485
 
409
486
  return (
@@ -421,70 +498,92 @@ export function ListingCard(props: ListingCardProps): ReactElement {
421
498
  {...(status !== undefined
422
499
  ? { "data-listing-status": status.status }
423
500
  : {})}
424
- // The body's own padding is zero because the ANCHOR fills the card:
425
- // padding outside it would be a strip of card that looks pressable and
426
- // is not. The text block inside the anchor carries the same padding
427
- // back, from the same token.
501
+ // The body's own padding is zero because the frame fills the card and
502
+ // the photo runs edge to edge when it is stacked: padding here would
503
+ // be a strip of card around a picture. The text block inside the
504
+ // anchor carries the same padding back, from the same token, and the
505
+ // row arm states its own inset (`--listing-card-inset`).
428
506
  styles={{ body: { minWidth: 0, padding: 0 } }}
429
- style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
507
+ style={{
508
+ ["--listing-card-focus" as string]: token.colorPrimary,
509
+ ["--listing-card-inset" as string]: `${String(token.paddingSM)}px`,
510
+ }}
430
511
  >
431
- <CardTarget {...props} listingId={listing.id} label={targetLabel}>
432
- {content}
433
- </CardTarget>
512
+ <div className={CARD_QUERY_CLASS}>
513
+ <div className={`${CARD_FRAME_CLASS} ${CARD_BLEED_CLASS}`}>
514
+ {/* The photos, OUTSIDE the anchor — see `<ListingPhotoStrip>`.
515
+ A swipeable strip is a control, and a link may not contain
516
+ one; the anchor still covers everything a person reads. */}
517
+ <div className={CARD_MEDIA_CLASS}>
518
+ <ListingPhotoStrip
519
+ images={listing.images ?? []}
520
+ title={title.length > 0 ? title : String(listing.id)}
521
+ testId="listings-card-photos"
522
+ />
523
+ </div>
434
524
 
435
- {/* The heart, and only the heart, lives OUTSIDE the anchor: a button
436
- inside a link is neither valid HTML nor operable. Its refusal gets
437
- a line of its own here, which is the whole reason it is a row under
438
- the card rather than a glyph floating on the photograph. */}
439
- {props.showFavorite === false ? null : (
440
- <div
441
- style={{
442
- paddingInline: token.paddingSM,
443
- paddingBlockEnd: token.paddingSM,
444
- }}
445
- >
446
- <GatedControl
447
- gate={favorite.gate}
448
- testId="listings-card-actions"
449
- style={{ width: "100%" }}
450
- >
451
- {(bind) => (
452
- <Flex justify="flex-end" style={{ width: "100%" }}>
453
- <Button
454
- disabled={bind.disabled}
455
- data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
456
- {...(bind["aria-describedby"] !== undefined
457
- ? { "aria-describedby": bind["aria-describedby"] }
458
- : {})}
459
- aria-label={favoriteLabel}
460
- aria-pressed={favorite.favorited}
461
- data-testid="listings-card-favorite"
462
- data-favorited={String(favorite.favorited)}
463
- data-analytics="none"
464
- data-analytics-reason="business action — host app wraps with its own tracked()"
465
- onClick={favorite.toggle}
466
- icon={<HeartIcon filled={favorite.favorited} />}
467
- />
468
- </Flex>
469
- )}
470
- </GatedControl>
525
+ <div className={CARD_MAIN_CLASS}>
526
+ <CardTarget {...props} listingId={listing.id} label={targetLabel}>
527
+ {content}
528
+ </CardTarget>
471
529
 
472
- {/* The door. `GatedControl` above already prints the reason and
473
- wires `aria-describedby` to it; what it cannot know is WHERE a
474
- visitor signs in, which is the container's business and arrives
475
- as `signIn`. On a grid `blockedReason="line"` drops the door and
476
- keeps the sentence — twenty-four doors to one place is not
477
- twenty-four pieces of help. */}
478
- {favoriteGate.reason === undefined || blockedReason === "line" ? null : (
479
- <Typography.Text
480
- type="secondary"
481
- data-testid="listings-card-favorite-blocked"
482
- >
483
- <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
484
- </Typography.Text>
485
- )}
530
+ {/* The heart, and only the heart, is a separate CONTROL outside the
531
+ anchor: a button inside a link is neither valid HTML nor operable.
532
+ Its refusal gets a line of its own here, which is the whole reason
533
+ it is a row under the card rather than a glyph floating on the
534
+ photograph. */}
535
+ {props.showFavorite === false ? null : (
536
+ <div
537
+ style={{
538
+ paddingInline: token.paddingSM,
539
+ paddingBlockEnd: token.paddingSM,
540
+ }}
541
+ >
542
+ <GatedControl
543
+ gate={favorite.gate}
544
+ testId="listings-card-actions"
545
+ style={{ width: "100%" }}
546
+ >
547
+ {(bind) => (
548
+ <Flex justify="flex-end" style={{ width: "100%" }}>
549
+ <Button
550
+ disabled={bind.disabled}
551
+ data-disabled-reason="the enclosing <GatedControl> renders the gate's reason beside this button"
552
+ {...(bind["aria-describedby"] !== undefined
553
+ ? { "aria-describedby": bind["aria-describedby"] }
554
+ : {})}
555
+ aria-label={favoriteLabel}
556
+ aria-pressed={favorite.favorited}
557
+ data-testid="listings-card-favorite"
558
+ data-favorited={String(favorite.favorited)}
559
+ data-analytics="none"
560
+ data-analytics-reason="business action — host app wraps with its own tracked()"
561
+ onClick={favorite.toggle}
562
+ icon={<HeartIcon filled={favorite.favorited} />}
563
+ />
564
+ </Flex>
565
+ )}
566
+ </GatedControl>
567
+
568
+ {/* The door. `GatedControl` above already prints the reason and
569
+ wires `aria-describedby` to it; what it cannot know is WHERE a
570
+ visitor signs in, which is the container's business and arrives
571
+ as `signIn`. On a grid `blockedReason="line"` drops the door and
572
+ keeps the sentence — twenty-four doors to one place is not
573
+ twenty-four pieces of help. */}
574
+ {favoriteGate.reason === undefined || blockedReason === "line" ? null : (
575
+ <Typography.Text
576
+ type="secondary"
577
+ data-testid="listings-card-favorite-blocked"
578
+ >
579
+ <SignInLink cta={props.signIn} testId="listings-card-sign-in" />
580
+ </Typography.Text>
581
+ )}
582
+ </div>
583
+ )}
584
+ </div>
486
585
  </div>
487
- )}
586
+ </div>
488
587
  </Card>
489
588
  </SkinTheme>
490
589
  );
@@ -25,6 +25,7 @@ import type { CSSProperties, ReactElement } from "react";
25
25
  import { useMemo } from "react";
26
26
  import { Typography, theme as antdTheme } from "antd";
27
27
  import { Image } from "@stapel/image";
28
+ import { SkinCarousel } from "@stapel/tokens-antd/skin";
28
29
  import { useT } from "@stapel/core";
29
30
  import { radii, spacing } from "@stapel/tokens";
30
31
  import { useListingsRuntime } from "../model/context.js";
@@ -133,3 +134,66 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
133
134
  />
134
135
  );
135
136
  }
137
+
138
+ /**
139
+ * A listing's photos as ONE swipeable strip — the shape every card surface in
140
+ * this pair uses, so there is one gallery and not three.
141
+ *
142
+ * ── Why the strip is never inside a card's anchor ──────────────────────────
143
+ *
144
+ * A `<SkinCarousel>` is a scroll container with its own tab stop, and a
145
+ * horizontal swipe that ends inside an `<a>` is a swipe the browser may
146
+ * deliver as a click: every attempt to look at photo two would open the
147
+ * listing. A link may not contain a control, and a swipeable strip is a
148
+ * control. So a card renders this as a SIBLING of its anchor and keeps the
149
+ * anchor around everything a person READS — which is the arrangement
150
+ * `<ListingSerpCard>` has shipped since it existed, and the one the live phone
151
+ * SERP was measured correct on while the desktop card (a still `<img>` inside
152
+ * the anchor, one photo, no dots) was not.
153
+ *
154
+ * A listing with no photos still gets one slide, so a row's height does not
155
+ * depend on whether a seller uploaded anything, and a ONE-photo strip gets
156
+ * neither peek nor dots: the sliver of a next slide is an affordance for
157
+ * something that is there.
158
+ */
159
+ export function ListingPhotoStrip(props: {
160
+ /** The stored references, in the seller's order. */
161
+ readonly images: readonly string[];
162
+ /** The listing's title — the fallback alt for a single photo. */
163
+ readonly title: string;
164
+ /** The surface's own test id, so a screen holding two kinds of card does
165
+ * not hand a test two elements under one name. */
166
+ readonly testId: string;
167
+ }): ReactElement {
168
+ const t = useT();
169
+ const { images, title } = props;
170
+ const many = images.length > 1;
171
+ return (
172
+ <SkinCarousel
173
+ label={t(LISTINGS_I18N_KEYS.cardPhotos)}
174
+ aspectRatio={LISTING_PHOTO_ASPECT}
175
+ peek={many}
176
+ dots={many}
177
+ data-testid={props.testId}
178
+ >
179
+ {images.length === 0 ? (
180
+ <ListingPhoto imageRef={undefined} alt={title} />
181
+ ) : (
182
+ images.map((reference, index) => (
183
+ <ListingPhoto
184
+ key={reference}
185
+ imageRef={reference}
186
+ alt={
187
+ many
188
+ ? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
189
+ index: index + 1,
190
+ total: images.length,
191
+ })
192
+ : title
193
+ }
194
+ />
195
+ ))
196
+ )}
197
+ </SkinCarousel>
198
+ );
199
+ }