@stapel/listings-react 0.20.0 → 0.22.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 +75 -0
  2. package/dist/api/generated/schema.d.ts +125 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +20 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +1 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +32 -2
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +7 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/ListingCard.d.ts +11 -0
  13. package/dist/default/ListingCard.d.ts.map +1 -1
  14. package/dist/default/ListingCard.js +86 -7
  15. package/dist/default/ListingCard.js.map +1 -1
  16. package/dist/default/ListingPhoto.d.ts +40 -0
  17. package/dist/default/ListingPhoto.d.ts.map +1 -1
  18. package/dist/default/ListingPhoto.js +53 -6
  19. package/dist/default/ListingPhoto.js.map +1 -1
  20. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  21. package/dist/default/ListingSerpCard.js +14 -4
  22. package/dist/default/ListingSerpCard.js.map +1 -1
  23. package/dist/default/MyListingsPane.d.ts +19 -1
  24. package/dist/default/MyListingsPane.d.ts.map +1 -1
  25. package/dist/default/MyListingsPane.js +99 -12
  26. package/dist/default/MyListingsPane.js.map +1 -1
  27. package/dist/headless/ListingActions.d.ts +64 -13
  28. package/dist/headless/ListingActions.d.ts.map +1 -1
  29. package/dist/headless/ListingActions.js +60 -5
  30. package/dist/headless/ListingActions.js.map +1 -1
  31. package/dist/i18n/es.d.ts.map +1 -1
  32. package/dist/i18n/es.js +8 -2
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +20 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +52 -1
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts.map +1 -1
  39. package/dist/i18n/ru.js +12 -2
  40. package/dist/i18n/ru.js.map +1 -1
  41. package/dist/index.d.ts +5 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/model/mine.d.ts +20 -0
  46. package/dist/model/mine.d.ts.map +1 -1
  47. package/dist/model/mine.js +22 -0
  48. package/dist/model/mine.js.map +1 -1
  49. package/dist/model/mutations.d.ts +24 -1
  50. package/dist/model/mutations.d.ts.map +1 -1
  51. package/dist/model/mutations.js +28 -0
  52. package/dist/model/mutations.js.map +1 -1
  53. package/dist/model/status.d.ts.map +1 -1
  54. package/dist/model/status.js +26 -9
  55. package/dist/model/status.js.map +1 -1
  56. package/dist/model/transitions.d.ts +38 -1
  57. package/dist/model/transitions.d.ts.map +1 -1
  58. package/dist/model/transitions.js +73 -1
  59. package/dist/model/transitions.js.map +1 -1
  60. package/llms.txt +4 -2
  61. package/manifest.json +26 -2
  62. package/nav-manifest.json +1 -1
  63. package/package.json +5 -5
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/generated/schema.ts +125 -2
  66. package/src/api/listingsApi.ts +27 -0
  67. package/src/api/types.ts +47 -2
  68. package/src/default/ListingCard.tsx +115 -12
  69. package/src/default/ListingPhoto.tsx +102 -18
  70. package/src/default/ListingSerpCard.tsx +17 -4
  71. package/src/default/MyListingsPane.tsx +205 -39
  72. package/src/headless/ListingActions.tsx +137 -16
  73. package/src/i18n/es.ts +8 -2
  74. package/src/i18n/keys.ts +52 -1
  75. package/src/i18n/ru.ts +12 -2
  76. package/src/index.ts +19 -3
  77. package/src/model/mine.ts +23 -0
  78. package/src/model/mutations.ts +41 -0
  79. package/src/model/status.ts +27 -9
  80. package/src/model/transitions.ts +80 -1
@@ -107,7 +107,12 @@
107
107
  import type { CSSProperties, ReactElement, ReactNode } from "react";
108
108
  import { Card, Flex, Typography, theme as antdTheme } from "antd";
109
109
  import { SkinButton as Button } from "@stapel/tokens-antd/skin";
110
- import { ErrorAlert, GatedControl, SkinTheme } from "@stapel/tokens-antd/skin";
110
+ import {
111
+ ErrorAlert,
112
+ GatedControl,
113
+ SKIN_CAROUSEL_SLIDE_CLASS,
114
+ SkinTheme,
115
+ } from "@stapel/tokens-antd/skin";
111
116
  import { useActionGate, useT } from "@stapel/core";
112
117
  import type { LinkComponent, SignInCtaProp } from "@stapel/core";
113
118
  import { spacing } from "@stapel/tokens";
@@ -123,7 +128,7 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
123
128
  import { GateReasonPopover } from "./GateReasonPopover.js";
124
129
  import { HeartIcon } from "./icons.js";
125
130
  import { SignInLink } from "./SignInLink.js";
126
- import { ListingPhotoStrip } from "./ListingPhoto.js";
131
+ import { PHOTO_LINK_CLASS, ListingPhotoStrip } from "./ListingPhoto.js";
127
132
  import { ListingPrice } from "./ListingPrice.js";
128
133
  import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
129
134
 
@@ -230,6 +235,18 @@ export const CARD_BLEED_CLASS = "stapel-listing-card-bleed";
230
235
  */
231
236
  export const CARD_VIEWED_CLASS = "stapel-listing-card-seen";
232
237
 
238
+ /**
239
+ * The class that makes a card LOOK like a target.
240
+ *
241
+ * Measured on a live 1440px grid before this pack: `box-shadow: none`,
242
+ * `transform: none`, the border unchanged and `cursor: auto` — a card that
243
+ * opens a listing and gives a person no reason to believe it does. antd's own
244
+ * `hoverable` was not used: it hard-codes `boxShadowCard` and nothing else, so
245
+ * it cannot state a border and cannot be told to stand still for somebody who
246
+ * asked their system for less motion.
247
+ */
248
+ export const CARD_HOVER_CLASS = "stapel-listing-card-hoverable";
249
+
233
250
  /**
234
251
  * How far a seen card is dimmed.
235
252
  *
@@ -289,14 +306,41 @@ export function cardTargetCss(): string {
289
306
  const media = `.${CARD_MEDIA_CLASS}`;
290
307
  const main = `.${CARD_MAIN_CLASS}`;
291
308
  const bleed = `.${CARD_BLEED_CLASS}`;
309
+ const hover = CARD_HOVER_CLASS;
292
310
  return [
293
311
  `.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
294
312
  `.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
313
+ // A PRESS, answered where the press lands (D176). The rule is on the
314
+ // TARGET rather than on the card's frame because that is the one element
315
+ // all three surfaces share: the feed tile is a bare div with this anchor
316
+ // over it and no card chrome at all, so a rule written for the frame
317
+ // reached the two surfaces that have one and missed the phone's home
318
+ // screen entirely. `matchMedia("(hover: hover)")` is false on that
319
+ // device, which makes this the ONLY feedback a finger ever gets between
320
+ // touching a tile and the next screen arriving.
321
+ `.${CARD_TARGET_CLASS}{transition:transform 90ms ease}`,
322
+ `.${CARD_TARGET_CLASS}:active{transform:scale(0.985)}`,
323
+ `@media (prefers-reduced-motion:reduce){.${CARD_TARGET_CLASS}{transition:none}` +
324
+ `.${CARD_TARGET_CLASS}:active{transform:none;opacity:0.9}}`,
295
325
  // The card asks about its OWN width, not the window's — see
296
326
  // `LISTING_CARD_ROW_MIN`.
297
327
  `${q}{container-type:inline-size}`,
298
328
  `${frame}{display:flex;flex-direction:column;min-inline-size:0}`,
299
- `${media}{min-inline-size:0}`,
329
+ // THE MEDIA WELL IS CLIPPED TO THE CARD'S OWN CORNER (D180).
330
+ //
331
+ // The photo strip runs edge to edge at the top of a stacked card, so the
332
+ // card's rounded corner is the corner of the PICTURE. It was not being
333
+ // cut to it: the well was a square box inside a rounded card, and each
334
+ // carousel slide carried a radius of its own — so a multi-photo tile drew
335
+ // a rounded sliver of the next photograph in the corner where the card's
336
+ // curve should be, and every row of tiles on the live feed read as a set
337
+ // of pictures with a torn right edge. One radius, stated by the card,
338
+ // applied to the box that holds the pictures; the slides inside it are
339
+ // square because the well is what has the shape.
340
+ `${media}{min-inline-size:0;overflow:hidden;` +
341
+ `border-start-start-radius:var(--listing-card-radius);` +
342
+ `border-start-end-radius:var(--listing-card-radius)}`,
343
+ `${media} .${SKIN_CAROUSEL_SLIDE_CLASS}{border-radius:0}`,
300
344
  `${main}{display:flex;flex-direction:column;flex:1 1 auto;min-inline-size:0}`,
301
345
  // The row arm. `align-items:flex-start` so a short text column does not
302
346
  // stretch the photo, and a fixed media basis so the picture cannot grow
@@ -304,6 +348,9 @@ export function cardTargetCss(): string {
304
348
  `@container (min-width:${String(LISTING_CARD_ROW_MIN)}px){` +
305
349
  `${frame}{flex-direction:row;align-items:flex-start}` +
306
350
  `${media}{flex:0 0 ${String(LISTING_CARD_ROW_MEDIA)}px;` +
351
+ // Inset on every side in the row arm, so the photo is a rounded block
352
+ // beside the text rather than two rounded corners and two square ones.
353
+ `border-radius:var(--listing-card-radius);` +
307
354
  `max-inline-size:${String(LISTING_CARD_ROW_MEDIA)}px}` +
308
355
  // A full-bleed stacked card has no padding of its own to give the row
309
356
  // arm, so the row arm states its own; the reading column's padding is
@@ -311,6 +358,34 @@ export function cardTargetCss(): string {
311
358
  `${bleed}{padding-block:var(--listing-card-inset);` +
312
359
  `padding-inline-start:var(--listing-card-inset)}` +
313
360
  `}`,
361
+ // A linked slide. `display:block` kills the inline baseline gap under the
362
+ // picture, which on a grid of tiles reads as cards of unequal height; the
363
+ // ring is for a browser that focuses an `aria-hidden` anchor anyway.
364
+ `.${PHOTO_LINK_CLASS}{display:block}`,
365
+ `.${PHOTO_LINK_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
366
+ // The card says it is a target. Two properties, both from the theme's own
367
+ // tokens, and a transition that a person who asked for less motion does
368
+ // not get.
369
+ `.${hover}{transition:box-shadow 160ms ease,border-color 160ms ease}`,
370
+ // HOVER IS FOR A POINTER THAT HAS ONE. Measured on the phone this loop
371
+ // walks: `matchMedia("(hover: hover)")` is false, and an unguarded
372
+ // `:hover` on a touch device is worse than absent — it latches after a
373
+ // tap and leaves one tile lit until something else is touched.
374
+ `@media (hover:hover){.${hover}:hover{` +
375
+ `box-shadow:var(--listing-card-hover-shadow);` +
376
+ `border-color:var(--listing-card-focus)}}`,
377
+ // PRESSED, and not the same thing as hovered. The card had a hover state
378
+ // and nothing at all on the way down, so a tap on a phone — where there
379
+ // is no hover to begin with — gave no feedback between the finger landing
380
+ // and the next screen arriving. The lift is TAKEN AWAY rather than
381
+ // increased: a pressed card is a card pushed into the page, which is the
382
+ // one direction a shadow can say without a second token.
383
+ `.${hover}:active{box-shadow:none;border-color:var(--listing-card-focus)}`,
384
+ // The photo is the largest target on the tile and is its own link, so it
385
+ // answers a press on its own — the card behind it may not be under the
386
+ // finger at all.
387
+ `.${PHOTO_LINK_CLASS}:active{opacity:0.88}`,
388
+ `@media (prefers-reduced-motion:reduce){.${hover}{transition:none}}`,
314
389
  // Already seen. The photo and everything inside the card's anchor dim
315
390
  // together; the heart is outside the anchor and stays as it was — see
316
391
  // CARD_VIEWED_CLASS.
@@ -553,9 +628,23 @@ export function ListingCard(props: ListingCardProps): ReactElement {
553
628
  />
554
629
  ) : null}
555
630
 
631
+ {/* ONE line, and it truncates (D185). Measured on a live 1440px feed:
632
+ a two-part city-and-district name wrapped, the text block grew
633
+ 104 -> 128px, and the two cards on either side of it stood 24px
634
+ shorter — a row of tiles with a ragged bottom edge and a heart
635
+ hanging below the line it belongs to. The place is a subtitle, not
636
+ prose: which city it is reads from the first words, and the tile's
637
+ height must be a property of the GRID rather than of how long this
638
+ particular neighbourhood is called. The full string stays in the
639
+ title attribute the way `ellipsis` puts it there, so nothing is
640
+ lost — it is one hover, or the listing's own page. */}
556
641
  {listing.location_label !== undefined &&
557
642
  listing.location_label.length > 0 ? (
558
- <Typography.Text type="secondary" data-testid="listings-card-location">
643
+ <Typography.Text
644
+ type="secondary"
645
+ ellipsis={{ tooltip: listing.location_label }}
646
+ data-testid="listings-card-location"
647
+ >
559
648
  {listing.location_label}
560
649
  </Typography.Text>
561
650
  ) : null}
@@ -577,11 +666,12 @@ export function ListingCard(props: ListingCardProps): ReactElement {
577
666
  {...(status !== undefined
578
667
  ? { "data-listing-status": status.status }
579
668
  : {})}
580
- // Both only when the server actually said so: absent and `null` leave
581
- // the card byte-identical to what it renders today.
582
- {...(viewed
583
- ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
584
- : {})}
669
+ // `viewed` only when the server actually said so: absent and `null`
670
+ // leave the card's dimming exactly as it renders today.
671
+ className={
672
+ viewed ? `${CARD_HOVER_CLASS} ${CARD_VIEWED_CLASS}` : CARD_HOVER_CLASS
673
+ }
674
+ {...(viewed ? { "data-listing-viewed": "true" } : {})}
585
675
  // The body's own padding is zero because the frame fills the card and
586
676
  // the photo runs edge to edge when it is stacked: padding here would
587
677
  // be a strip of card around a picture. The text block inside the
@@ -591,18 +681,31 @@ export function ListingCard(props: ListingCardProps): ReactElement {
591
681
  style={{
592
682
  ["--listing-card-focus" as string]: token.colorPrimary,
593
683
  ["--listing-card-inset" as string]: `${String(token.paddingSM)}px`,
684
+ // The card's OWN corner, handed to the media well so the picture is
685
+ // cut to the same curve the card is drawn with — in whatever theme,
686
+ // at whatever radius a deployment retuned it to.
687
+ ["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
688
+ // The theme's own elevation, so the lift is the same one every
689
+ // raised surface in the skin uses and it is right in both modes.
690
+ ["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
594
691
  }}
595
692
  >
596
693
  <div className={CARD_QUERY_CLASS}>
597
694
  <div className={`${CARD_FRAME_CLASS} ${CARD_BLEED_CLASS}`}>
598
- {/* The photos, OUTSIDE the anchor — see `<ListingPhotoStrip>`.
599
- A swipeable strip is a control, and a link may not contain
600
- one; the anchor still covers everything a person reads. */}
695
+ {/* The STRIP is outside the anchor — a swipeable scroller is a
696
+ control and a link may not contain one — but each SLIDE is
697
+ linked, so the 267x200 photograph is part of the card's target
698
+ instead of the one place on it where a click does nothing.
699
+ See `<ListingPhotoStrip>` for where that line is drawn. */}
601
700
  <div className={CARD_MEDIA_CLASS}>
602
701
  <ListingPhotoStrip
603
702
  images={listing.images ?? []}
604
703
  title={title.length > 0 ? title : String(listing.id)}
605
704
  testId="listings-card-photos"
705
+ {...(props.href !== undefined ? { href: props.href } : {})}
706
+ {...(props.href !== undefined && props.linkComponent !== undefined
707
+ ? { linkComponent: props.linkComponent }
708
+ : {})}
606
709
  />
607
710
  </div>
608
711
 
@@ -27,10 +27,22 @@ import { Typography, theme as antdTheme } from "antd";
27
27
  import { Image } from "@stapel/image";
28
28
  import { SkinCarousel } from "@stapel/tokens-antd/skin";
29
29
  import { useT } from "@stapel/core";
30
+ import type { LinkComponent } from "@stapel/core";
30
31
  import { radii, spacing } from "@stapel/tokens";
31
32
  import { useListingsRuntime } from "../model/context.js";
32
33
  import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
33
34
 
35
+
36
+ /**
37
+ * The class a linked slide carries.
38
+ *
39
+ * `display:block` is the load-bearing half: an inline anchor around a block
40
+ * image leaves a baseline gap under the picture, which on a grid of tiles is a
41
+ * visible row of mismatched card heights. The rule itself lives in
42
+ * `cardTargetCss()`, beside the card's other one-rule-per-defect entries.
43
+ */
44
+ export const PHOTO_LINK_CLASS = "stapel-listing-photo-link";
45
+
34
46
  /** The default shape of a listing photo box everywhere in this skin. Declared
35
47
  * once so a card, a detail hero and a dashboard thumbnail cannot drift into
36
48
  * three different crops. */
@@ -151,6 +163,28 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
151
163
  * SERP was measured correct on while the desktop card (a still `<img>` inside
152
164
  * the anchor, one photo, no dots) was not.
153
165
  *
166
+ * ── …and why each SLIDE may still be one, when a card asks (`href`) ────────
167
+ *
168
+ * The rule above is about the STRIP, and it was read as a rule about the
169
+ * picture. It is not, and the difference was measured: on a 1440px grid the
170
+ * photo is 267x200 — the largest, most obvious target on the card — and
171
+ * clicking it did nothing at all. `cursor: auto`, no hover, and the visitor
172
+ * stayed on the page they were already on. A person who clicks a picture of a
173
+ * phone is asking to see the phone; there is no reading of that click in which
174
+ * the right answer is silence.
175
+ *
176
+ * So a card may hand this strip its own `href`, and each slide becomes a link
177
+ * around its picture. What that does NOT do is make the strip a link: the
178
+ * scroller, its arrows and its dots stay controls, outside every anchor,
179
+ * because they are what the header's rule is actually protecting. And the
180
+ * slide links are `tabIndex={-1}` + `aria-hidden`, so a card is still ONE tab
181
+ * stop and one accessible name — the reading anchor's — rather than one per
182
+ * photograph.
183
+ *
184
+ * A surface that does not pass `href` is byte-identical to before. The phone
185
+ * SERP passes none: a horizontal swipe is a real gesture there, and it is the
186
+ * gesture the header's rule was written for.
187
+ *
154
188
  * A listing with no photos still gets one slide, so a row's height does not
155
189
  * depend on whether a seller uploaded anything, and a ONE-photo strip gets
156
190
  * neither peek nor dots: the sliver of a next slide is an affordance for
@@ -164,10 +198,59 @@ export function ListingPhotoStrip(props: {
164
198
  /** The surface's own test id, so a screen holding two kinds of card does
165
199
  * not hand a test two elements under one name. */
166
200
  readonly testId: string;
201
+ /**
202
+ * Where a click on the PICTURE goes — the card's own `href`, when the card
203
+ * wants its photograph to be part of its target. Omitted, every slide is
204
+ * inert exactly as it has always been.
205
+ */
206
+ readonly href?: string;
207
+ /** The host's `<Link>`, so the click stays inside the SPA. */
208
+ readonly linkComponent?: LinkComponent;
167
209
  }): ReactElement {
168
210
  const t = useT();
169
211
  const { images, title } = props;
170
212
  const many = images.length > 1;
213
+
214
+ /**
215
+ * One slide, linked or not.
216
+ *
217
+ * `aria-hidden` + `tabIndex={-1}`: this is a SECOND way to reach a
218
+ * destination the card already names, not a second destination. Without it a
219
+ * grid of twenty-four cards grows twenty-four extra tab stops, each
220
+ * announcing the same listing the card announced a moment ago.
221
+ */
222
+ const slide = (photo: ReactElement, key: string): ReactElement => {
223
+ if (props.href === undefined) return photo;
224
+ const Link = props.linkComponent;
225
+ return Link !== undefined ? (
226
+ <Link
227
+ key={key}
228
+ href={props.href}
229
+ className={PHOTO_LINK_CLASS}
230
+ aria-hidden="true"
231
+ tabIndex={-1}
232
+ data-testid="listings-photo-link"
233
+ data-analytics="none"
234
+ data-analytics-reason="business action — host app wraps with its own tracked()"
235
+ >
236
+ {photo}
237
+ </Link>
238
+ ) : (
239
+ <a
240
+ key={key}
241
+ href={props.href}
242
+ className={PHOTO_LINK_CLASS}
243
+ aria-hidden="true"
244
+ tabIndex={-1}
245
+ data-testid="listings-photo-link"
246
+ data-analytics="none"
247
+ data-analytics-reason="business action — host app wraps with its own tracked()"
248
+ >
249
+ {photo}
250
+ </a>
251
+ );
252
+ };
253
+
171
254
  return (
172
255
  <SkinCarousel
173
256
  label={t(LISTINGS_I18N_KEYS.cardPhotos)}
@@ -176,24 +259,25 @@ export function ListingPhotoStrip(props: {
176
259
  dots={many}
177
260
  data-testid={props.testId}
178
261
  >
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
- )}
262
+ {images.length === 0
263
+ ? slide(<ListingPhoto imageRef={undefined} alt={title} />, "empty")
264
+ : images.map((reference, index) =>
265
+ slide(
266
+ <ListingPhoto
267
+ key={reference}
268
+ imageRef={reference}
269
+ alt={
270
+ many
271
+ ? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
272
+ index: index + 1,
273
+ total: images.length,
274
+ })
275
+ : title
276
+ }
277
+ />,
278
+ reference
279
+ )
280
+ )}
197
281
  </SkinCarousel>
198
282
  );
199
283
  }
@@ -99,6 +99,7 @@ import {
99
99
  CARD_MEDIA_CLASS,
100
100
  CARD_QUERY_CLASS,
101
101
  CARD_TARGET_STYLE_HREF,
102
+ CARD_HOVER_CLASS,
102
103
  CARD_VIEWED_CLASS,
103
104
  CardTarget,
104
105
  cardTargetCss,
@@ -247,11 +248,23 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
247
248
  {...(status !== undefined
248
249
  ? { "data-listing-status": status.status }
249
250
  : {})}
250
- {...(viewed
251
- ? { className: CARD_VIEWED_CLASS, "data-listing-viewed": "true" }
252
- : {})}
251
+ // THE PHONE CARD SAYS IT IS A TARGET, TOO (D176). It carried no
252
+ // hover and no pressed state at all, and the device this card exists
253
+ // for answers `matchMedia("(hover: hover)")` with false — so a tap
254
+ // gave no feedback whatever until the next screen arrived. The shared
255
+ // class is one rule for a pointer that hovers and one for a finger
256
+ // that presses; `cardTargetCss` states both.
257
+ className={
258
+ viewed ? `${CARD_HOVER_CLASS} ${CARD_VIEWED_CLASS}` : CARD_HOVER_CLASS
259
+ }
260
+ {...(viewed ? { "data-listing-viewed": "true" } : {})}
253
261
  styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
254
- style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
262
+ style={{
263
+ ["--listing-card-focus" as string]: token.colorPrimary,
264
+ // The corner the media well is cut to — see `cardTargetCss` (D180).
265
+ ["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
266
+ ["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
267
+ }}
255
268
  >
256
269
  <div className={CARD_QUERY_CLASS}>
257
270
  <div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>