@stapel/search-react 0.12.0 → 0.14.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 (43) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/MODULE.md +7 -3
  3. package/dist/default/LocationSummaryLine.d.ts +19 -0
  4. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  5. package/dist/default/LocationSummaryLine.js +111 -11
  6. package/dist/default/LocationSummaryLine.js.map +1 -1
  7. package/dist/default/SearchResultCard.d.ts.map +1 -1
  8. package/dist/default/SearchResultCard.js +83 -37
  9. package/dist/default/SearchResultCard.js.map +1 -1
  10. package/dist/default/cardPhotos.d.ts +65 -0
  11. package/dist/default/cardPhotos.d.ts.map +1 -0
  12. package/dist/default/cardPhotos.js +127 -0
  13. package/dist/default/cardPhotos.js.map +1 -0
  14. package/dist/i18n/es.d.ts.map +1 -1
  15. package/dist/i18n/es.js +3 -0
  16. package/dist/i18n/es.js.map +1 -1
  17. package/dist/i18n/keys.d.ts +10 -0
  18. package/dist/i18n/keys.d.ts.map +1 -1
  19. package/dist/i18n/keys.js +13 -0
  20. package/dist/i18n/keys.js.map +1 -1
  21. package/dist/i18n/ru.d.ts.map +1 -1
  22. package/dist/i18n/ru.js +3 -0
  23. package/dist/i18n/ru.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/model/runtime.d.ts +40 -3
  28. package/dist/model/runtime.d.ts.map +1 -1
  29. package/dist/model/runtime.js +2 -1
  30. package/dist/model/runtime.js.map +1 -1
  31. package/llms.txt +3 -3
  32. package/manifest.json +14 -3
  33. package/nav-manifest.json +1 -1
  34. package/package.json +5 -5
  35. package/src/analytics/generated/events.json +1 -1
  36. package/src/default/LocationSummaryLine.tsx +150 -22
  37. package/src/default/SearchResultCard.tsx +157 -71
  38. package/src/default/cardPhotos.ts +195 -0
  39. package/src/i18n/es.ts +3 -0
  40. package/src/i18n/keys.ts +13 -0
  41. package/src/i18n/ru.ts +3 -0
  42. package/src/index.ts +1 -0
  43. package/src/model/runtime.ts +43 -4
@@ -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/search-react",
4
- "version": "0.12.0",
4
+ "version": "0.14.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -27,34 +27,139 @@
27
27
  *
28
28
  * `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
29
29
  * number nobody reads, so it shows a dot. This is a full-width row with a word
30
- * on it, so the badge has room to say HOW MANY constraints are applied — which
31
- * is the difference between "something is filtered" and "four things are, and
32
- * that is why there are three results". Both read the same
33
- * `activeFilters` off the URL state; neither invents a second counter.
30
+ * on it, so it has room to say HOW MANY constraints are applied — which is the
31
+ * difference between "something is filtered" and "four things are, and that is
32
+ * why there are three results". Both read the same `activeFilters` off the URL
33
+ * state; neither invents a second counter.
34
+ *
35
+ * ## The row overlapped itself on a phone, and why (defect C12)
36
+ *
37
+ * Measured on a live 390px SERP, in both themes, on every result page:
38
+ *
39
+ * ```
40
+ * {"kind":"clipped-left","t":"A chosen place on the map","x":-4}
41
+ * {"kind":"overlap","a":"· Within 25 km","b":"Filters","px":43}
42
+ * ```
43
+ *
44
+ * Three separate causes, and none of them was the flex row itself:
45
+ *
46
+ * 1. **An antd `<Button>` CENTRES its content.** `minWidth: 0` let the left
47
+ * item shrink, and nothing clipped what was inside it, so the label
48
+ * overflowed its box symmetrically — off the left edge of the screen at
49
+ * `x = -4` and 43px across the word "Filters" at the other end. A shrunk
50
+ * box with no `overflow` is not a truncation, it is an overlap.
51
+ * 2. **Nothing declared which end may shrink.** Both ends were `1 1 auto`, so
52
+ * a long place name took width from a word that must never lose any.
53
+ * 3. **The count was an antd `<Badge count>`,** which is an absolutely
54
+ * positioned `sup` hung off the top-right CORNER of what it wraps. At the
55
+ * trailing edge of a full-width row that lands it outside the row entirely
56
+ * — a red plaque floating in the corner of the page, attached to nothing
57
+ * (measured at `y = 72` for a row whose own line is at `y = 93`).
58
+ *
59
+ * So: one compact line, `place · radius` on the left and `Filters` right; the
60
+ * left is the only half that shrinks and it truncates with an ellipsis; the
61
+ * count rides IN the flow beside the word it counts for. The first two rules
62
+ * are what an inline style cannot reach (they belong on the wrapper antd puts
63
+ * around a button's children), which is why this file hoists a stylesheet the
64
+ * way `<ListingCard>` and `<SkinCarousel>` do.
34
65
  */
35
66
  import { useState } from "react";
36
67
  import type { CSSProperties, ReactElement, ReactNode } from "react";
37
- import { Badge, Button, Flex } from "antd";
68
+ import { Button, Flex } from "antd";
38
69
  import { useT } from "@stapel/core";
39
- import { spacing } from "@stapel/tokens";
70
+ import { cssVar, fontSize, radii, spacing } from "@stapel/tokens";
40
71
  import { useSearchState } from "../headless/SearchStateProvider.js";
41
72
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
42
73
  import { geoSummaryFallback } from "./FacetPanelPane.js";
43
74
  import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
44
75
  import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
45
76
 
77
+ /** The class the row carries, for {@link locationLineCss}. */
78
+ export const LOCATION_LINE_CLASS = "stapel-search-location-line";
79
+ /** The class the shrinking half carries. */
80
+ export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
81
+ /** The class the truncating label carries. */
82
+ export const LOCATION_LINE_LABEL_CLASS = "stapel-search-location-line-label";
83
+ /** The class the fixed half carries. */
84
+ export const LOCATION_LINE_END_CLASS = "stapel-search-location-line-end";
85
+
86
+ /** The `href` the hoisted stylesheet is deduplicated by. */
87
+ export const LOCATION_LINE_STYLE_HREF = "stapel-search-location-line";
88
+
89
+ /**
90
+ * The rules that keep the line ONE line — and that an inline style cannot
91
+ * reach, because they belong on the `<span>` antd wraps a button's children
92
+ * in. See the file header (defect C12) for what each of them stops.
93
+ *
94
+ * Static: nothing here varies per instance, so one hoisted copy serves a page
95
+ * in either theme.
96
+ */
97
+ export function locationLineCss(): string {
98
+ const row = `.${LOCATION_LINE_CLASS}`;
99
+ const where = `.${LOCATION_LINE_WHERE_CLASS}`;
100
+ const label = `.${LOCATION_LINE_LABEL_CLASS}`;
101
+ const end = `.${LOCATION_LINE_END_CLASS}`;
102
+ return [
103
+ // `min-width:0` on the row too: a flex item's default `min-width:auto`
104
+ // is what makes a nested flex row refuse to shrink at all.
105
+ `${row}{min-inline-size:0}`,
106
+ // The ONLY half that shrinks, and it CLIPS what it cannot show. An antd
107
+ // Button is already a flex row and it CENTRES its children, so without
108
+ // these two the box shrank and its content overflowed both edges at once
109
+ // — the whole defect: `x:-4` on the left, 43px over "Filters" on the
110
+ // right.
111
+ `${where}{flex:1 1 auto;min-inline-size:0;overflow:hidden;` +
112
+ `justify-content:flex-start}`,
113
+ // Whatever the button puts its children in has to be allowed to shrink;
114
+ // a flex item's `min-width:auto` refuses to go below its content.
115
+ `${where}>span{min-inline-size:0}`,
116
+ // `display:block` is load-bearing: `text-overflow` applies to a BLOCK
117
+ // container and does nothing on a flex one, so a label that had been
118
+ // turned into a flex box truncated with a hard cut mid-glyph instead of
119
+ // an ellipsis.
120
+ `${label}{display:block;min-inline-size:0;overflow:hidden;` +
121
+ `text-overflow:ellipsis;white-space:nowrap}`,
122
+ // The word a person is looking for never loses a pixel to a place name.
123
+ `${end}{flex:0 0 auto}`,
124
+ ].join("");
125
+ }
126
+
46
127
  /** The row. Both ends are text buttons, so the row reads as a line of type
47
128
  * rather than as two controls bolted onto a results page. */
48
129
  const ROW: CSSProperties = { width: "100%" };
49
130
 
50
- /** Each end shrinks before it wraps; the location is the half that may
51
- * ellipsis, because a long place name must not push "Filters" off screen. */
131
+ /** The shrinking half. The flex rules it needs live in the hoisted sheet
132
+ * (they have to reach antd's own wrapper span); this is the chrome. */
52
133
  const LOCATION: CSSProperties = {
53
- minWidth: 0,
54
134
  paddingInline: 0,
55
135
  textAlign: "start",
56
136
  };
57
137
 
138
+ /** The pin, which is not allowed to shrink either — a squashed glyph is
139
+ * noise, and it costs 16px. */
140
+ const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
141
+
142
+ /**
143
+ * The count, IN the flow.
144
+ *
145
+ * An antd `<Badge count>` is an absolutely positioned `sup` on the corner of
146
+ * whatever it wraps, and at the trailing edge of a full-width row that puts it
147
+ * outside the row: a red plaque in the corner of the page attached to nothing.
148
+ * A pill beside the word is the same fact, in the line it belongs to, and it
149
+ * takes part in the layout instead of floating over it.
150
+ */
151
+ const COUNT: CSSProperties = {
152
+ display: "inline-block",
153
+ minInlineSize: `${String(spacing[4])}px`,
154
+ paddingInline: spacing[1],
155
+ borderRadius: radii.full,
156
+ background: cssVar("brand"),
157
+ color: cssVar("text-on-accent"),
158
+ fontSize: fontSize.xs.fontSize,
159
+ lineHeight: `${String(spacing[4])}px`,
160
+ textAlign: "center",
161
+ };
162
+
58
163
  export interface LocationSummaryLineProps {
59
164
  /**
60
165
  * The location control the sheet opens onto — the same slot the panel and
@@ -123,18 +228,26 @@ export function LocationSummaryLine(
123
228
 
124
229
  return (
125
230
  <>
231
+ <style href={LOCATION_LINE_STYLE_HREF} precedence="default">
232
+ {locationLineCss()}
233
+ </style>
126
234
  <Flex
127
235
  align="center"
128
236
  justify="space-between"
129
237
  gap={spacing[2]}
130
238
  style={ROW}
239
+ className={LOCATION_LINE_CLASS}
131
240
  data-testid="search-location-summary"
132
241
  data-geo={geo === undefined ? "off" : "on"}
133
242
  >
243
+ {/* The glyph is rendered as a CHILD rather than through antd's `icon`
244
+ prop: the icon slot sits outside the wrapper span, so the label
245
+ beside it could not be given a min-width of its own — and a label
246
+ that cannot shrink is a label that overflows. */}
134
247
  <Button
135
248
  type="link"
136
249
  style={LOCATION}
137
- icon={<PinGlyph />}
250
+ className={LOCATION_LINE_WHERE_CLASS}
138
251
  data-testid="search-location-open"
139
252
  data-analytics="none"
140
253
  data-analytics-reason="opening the location sheet is a read, not a flow step"
@@ -142,13 +255,21 @@ export function LocationSummaryLine(
142
255
  setOpen(true);
143
256
  }}
144
257
  >
145
- {where}
146
- {radius !== undefined && (
147
- <span data-testid="search-location-radius">
148
- {" · "}
149
- {radius}
150
- </span>
151
- )}
258
+ <span style={PIN}>
259
+ <PinGlyph />
260
+ </span>
261
+ <span
262
+ className={LOCATION_LINE_LABEL_CLASS}
263
+ data-testid="search-location-label"
264
+ >
265
+ {where}
266
+ {radius !== undefined && (
267
+ <span data-testid="search-location-radius">
268
+ {" · "}
269
+ {radius}
270
+ </span>
271
+ )}
272
+ </span>
152
273
  </Button>
153
274
 
154
275
  {/* "Filters", not "All filters": this end of the row shares 390px
@@ -156,10 +277,12 @@ export function LocationSummaryLine(
156
277
  the person is looking for is the noun. The panel's own heading
157
278
  still says "All filters" — there it is naming a sheet, not a
158
279
  door. */}
159
- {/* The count, not a dot: this row has the width to say how many. */}
160
- <Badge
161
- count={activeFilters}
162
- size="small"
280
+ {/* The count, not a dot: this row has the width to say how many — and
281
+ it rides IN the line rather than floating off its corner. */}
282
+ <Flex
283
+ align="center"
284
+ gap={spacing[1]}
285
+ className={LOCATION_LINE_END_CLASS}
163
286
  data-testid="search-location-filters-badge"
164
287
  >
165
288
  <Button
@@ -173,7 +296,12 @@ export function LocationSummaryLine(
173
296
  >
174
297
  {t(SEARCH_I18N_KEYS.filtersShort)}
175
298
  </Button>
176
- </Badge>
299
+ {activeFilters > 0 && (
300
+ <span style={COUNT} data-testid="search-location-filters-count">
301
+ {activeFilters}
302
+ </span>
303
+ )}
304
+ </Flex>
177
305
  </Flex>
178
306
 
179
307
  <GeoSheet
@@ -18,11 +18,14 @@
18
18
  *
19
19
  * ── Two things this card used to get wrong ────────────────────────────────
20
20
  *
21
- * 1. **It declared `image_url` and drew nothing.** A text-only card in a
22
- * classifieds search is not a card; the field was in `GENERIC_CARD_FIELDS`
23
- * and in no render path. It is drawn now, through `@stapel/image`, so the
24
- * aspect box lands before the network does and a dead URL renders a named
25
- * placeholder instead of the browser's torn-page icon.
21
+ * 1. **It read a photo shape nothing in this fleet emits.** `card.image` was
22
+ * read as an object with a `url` key and `card.image_url` as the fallback;
23
+ * what the fleet actually stores is a `<type>/<hash>` CDN reference, and
24
+ * where it does store an object that object carries `ref` + `variants[]`
25
+ * and no top-level `url`. So every consumer that did not pass its own
26
+ * `renderCard` got a card with no photo at all. `cardPhotos.ts` reads the
27
+ * real shapes, through the runtime's `resolveImage` seam, and the whole
28
+ * `images[]` gallery rather than one photo.
26
29
  * 2. **The marking's EXPLANATION was in a `Tooltip`.** Touch has no hover, so
27
30
  * on the device most of this traffic arrives from, the sentence the law is
28
31
  * actually about was unreachable — the tag said "Promoted" and nothing on
@@ -47,11 +50,14 @@ import { useMemo } from "react";
47
50
  import type { CSSProperties, ReactElement, ReactNode } from "react";
48
51
  import { Card, Flex, Typography } from "antd";
49
52
  import { Image } from "@stapel/image";
50
- import type { StapelImage } from "@stapel/image";
53
+ import { SkinCarousel } from "@stapel/tokens-antd/skin";
51
54
  import { useFormat, useT } from "@stapel/core";
52
55
  import type { Format } from "@stapel/core";
53
56
  import { cssVar, fontSize, fontWeight, radii, spacing } from "@stapel/tokens";
54
57
  import type { SearchItem } from "../api/types.js";
58
+ import { useSearchRuntime } from "../model/context.js";
59
+ import { readCardPhotos } from "./cardPhotos.js";
60
+ import type { CardPhotos } from "./cardPhotos.js";
55
61
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
56
62
 
57
63
  /** What the card slot is handed. */
@@ -78,7 +84,11 @@ export const GENERIC_CARD_FIELDS: readonly string[] = [
78
84
  "price",
79
85
  "currency",
80
86
  "location",
81
- "image_url",
87
+ // The gallery, and the singular first photo the projection keeps beside it
88
+ // (`images[0]`). Both hold CDN references; see `cardPhotos.ts`. The old
89
+ // `image_url` is gone — no backend in this fleet ever wrote it.
90
+ "images",
91
+ "image",
82
92
  "url",
83
93
  ];
84
94
 
@@ -150,41 +160,130 @@ const PROMOTED_TAG: CSSProperties = {
150
160
  whiteSpace: "nowrap",
151
161
  };
152
162
 
153
- /** The photo's box: a 4:3 well the image fills, drawn before it loads. */
154
- const PHOTO: CSSProperties = {
163
+ /**
164
+ * The shape of one photo well, everywhere on this card. Declared once so the
165
+ * placeholder and the loaded photo cannot drift into two different crops, and
166
+ * so the well is on screen before the network answers.
167
+ */
168
+ const CARD_PHOTO_ASPECT = "4 / 3";
169
+
170
+ /** The placeholder well: the box the photo would have occupied. */
171
+ const PHOTO_ABSENT: CSSProperties = {
172
+ display: "flex",
173
+ flexDirection: "column",
174
+ alignItems: "center",
175
+ justifyContent: "center",
176
+ gap: spacing[1],
155
177
  width: "100%",
156
- aspectRatio: "4 / 3",
178
+ aspectRatio: CARD_PHOTO_ASPECT,
157
179
  overflow: "hidden",
158
180
  borderRadius: radii.md,
159
181
  background: cssVar("surface-sunken"),
182
+ color: cssVar("text-muted"),
160
183
  };
161
184
 
185
+ /** The slide's own fill — the strip's well owns the shape. */
186
+ const PHOTO_FILL: CSSProperties = { width: "100%", height: "100%" };
187
+
188
+ /** A camera outline in `currentColor` — no icon dependency, and it inherits
189
+ * the theme rather than carrying a colour. `aria-hidden` because the sentence
190
+ * beside it is the label. */
191
+ function CameraGlyph(): ReactElement {
192
+ return (
193
+ <svg
194
+ width="24"
195
+ height="24"
196
+ viewBox="0 0 24 24"
197
+ fill="none"
198
+ stroke="currentColor"
199
+ strokeWidth="1.5"
200
+ strokeLinecap="round"
201
+ strokeLinejoin="round"
202
+ aria-hidden="true"
203
+ >
204
+ <path d="M3 8.5A2.5 2.5 0 0 1 5.5 6h1.7l1.2-2h6.2l1.2 2h1.7A2.5 2.5 0 0 1 20 8.5v8A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5z" />
205
+ <circle cx="12" cy="12.5" r="3.2" />
206
+ </svg>
207
+ );
208
+ }
209
+
162
210
  /**
163
- * A plain `image_url` as the descriptor `<Image>` consumes.
211
+ * The card's photos the whole gallery, as a swipeable strip.
212
+ *
213
+ * ── Why the strip is OUTSIDE the card's anchor ────────────────────────────
214
+ *
215
+ * `<SkinCarousel>` is a scroll container with its own tab stop, and a
216
+ * horizontal swipe that ends inside an `<a>` is a swipe the browser may
217
+ * deliver as a click. Inside the anchor, every attempt to look at photo two
218
+ * would navigate to the result — the defect that makes phone galleries
219
+ * unusable. So it sits above the anchor as a sibling, exactly as
220
+ * `<ListingSerpCard>` does it, and the anchor still covers everything a
221
+ * person reads.
164
222
  *
165
- * A doc type that stores a bare URL has no variant ladder and no dimensions
166
- * `source: "link"` is exactly that case, and `<Image>` degrades to the single
167
- * URL rather than pretending to shop a tier. A doc type that stores the whole
168
- * `StapelImage` snapshot (`card.image`) gets the ladder, blur-up and all.
223
+ * Three states, and they are three different facts. No photo FIELD at all
224
+ * a doc type that indexes text — draws nothing, because reserving a 4:3 well
225
+ * for a corpus that has no photos is a hole in every row. A field with
226
+ * nothing behind it draws the well and says the photo is unavailable: that is
227
+ * usually an unwired `resolveImage`, and a sentence gets it fixed where an
228
+ * empty grey box does not. Anything else is the strip.
169
229
  */
170
- function cardImage(card: Readonly<Record<string, unknown>>): StapelImage | undefined {
171
- const rich = card["image"];
172
- if (rich !== null && typeof rich === "object" && "url" in rich) {
173
- return rich as StapelImage;
230
+ function CardPhotoStrip(props: {
231
+ photos: CardPhotos;
232
+ title: string;
233
+ }): ReactElement | null {
234
+ const t = useT();
235
+ const { photos, title } = props;
236
+ if (photos.stored === 0) return null;
237
+
238
+ if (photos.images.length === 0) {
239
+ const caption = t(SEARCH_I18N_KEYS.resultsPhotoUnavailable);
240
+ return (
241
+ <div
242
+ role="img"
243
+ aria-label={caption}
244
+ data-testid="search-result-photo-absent"
245
+ style={PHOTO_ABSENT}
246
+ >
247
+ <CameraGlyph />
248
+ <Typography.Text type="secondary" style={{ fontSize: fontSize.xs.fontSize }}>
249
+ {caption}
250
+ </Typography.Text>
251
+ </div>
252
+ );
174
253
  }
175
- const url = text(card["image_url"]);
176
- if (url === undefined) return undefined;
177
- return {
178
- source: "link",
179
- url,
180
- mime: null,
181
- width: null,
182
- height: null,
183
- aspect: null,
184
- square: false,
185
- preview_b64: null,
186
- variants: [],
187
- };
254
+
255
+ const total = photos.images.length;
256
+ // A one-photo strip gets neither a peek nor dots: the sliver of a next
257
+ // slide is an affordance for something that is there.
258
+ const many = total > 1;
259
+ return (
260
+ <SkinCarousel
261
+ label={t(SEARCH_I18N_KEYS.resultsPhotos)}
262
+ aspectRatio={CARD_PHOTO_ASPECT}
263
+ peek={many}
264
+ dots={many}
265
+ data-testid="search-result-photos"
266
+ >
267
+ {photos.images.map((image, index) => (
268
+ <Image
269
+ key={`${String(index)}:${image.url}`}
270
+ meta={image}
271
+ alt={
272
+ many
273
+ ? t(SEARCH_I18N_KEYS.resultsPhotoAlt, {
274
+ index: index + 1,
275
+ total,
276
+ title,
277
+ })
278
+ : t(SEARCH_I18N_KEYS.resultsImageAlt, { title })
279
+ }
280
+ fit="cover"
281
+ data-testid="search-result-photo"
282
+ style={PHOTO_FILL}
283
+ />
284
+ ))}
285
+ </SkinCarousel>
286
+ );
188
287
  }
189
288
 
190
289
  /**
@@ -208,6 +307,7 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
208
307
  const t = useT();
209
308
  const format = useFormat();
210
309
  const card = props.item.card;
310
+ const resolve = useSearchRuntime().resolveImage;
211
311
  const title = text(card["title"]) ?? t(SEARCH_I18N_KEYS.resultsUntitled);
212
312
  const price = formatCardPrice(format, text(card["price"]), text(card["currency"]));
213
313
  const href = text(card["url"]);
@@ -218,48 +318,32 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
218
318
  km: props.item.distance_km.toFixed(1),
219
319
  })
220
320
  : undefined;
221
- // Memoised on the two fields it reads: a fresh `meta` identity every render
222
- // is a load `<Image>` has to decide is or is not the same one.
223
- const image = useMemo(() => cardImage(card), [card]);
321
+ // Memoised on the card and the resolver: a host resolver is a plain
322
+ // function returning a fresh object, so resolving inline hands `<Image>` a
323
+ // new `meta` identity on every render — a load it then has to decide is or
324
+ // is not the same one.
325
+ const photos = useMemo(() => readCardPhotos(card, resolve), [card, resolve]);
224
326
 
225
327
  const body = (
226
- <Flex vertical gap={spacing[2]}>
227
- {image !== undefined && (
228
- <div style={PHOTO} data-testid="search-result-photo">
229
- {/* The WELL owns the shape (4:3, drawn before the network answers),
230
- so the image is told to fill it. Without an explicit box the
231
- image's own container reserves height only from the snapshot's
232
- `aspect` — which a doc type storing a bare `image_url` does not
233
- have — and a `cover` image inside a zero-height parent is a
234
- photo that loaded and was never seen. */}
235
- <Image
236
- meta={image}
237
- alt={t(SEARCH_I18N_KEYS.resultsImageAlt, { title })}
238
- fit="cover"
239
- style={{ width: "100%", height: "100%" }}
240
- />
241
- </div>
328
+ <Flex vertical gap={spacing[1]}>
329
+ <Typography.Text strong>{title}</Typography.Text>
330
+ {/* The price is the strongest line on a catalogue card: it is what the
331
+ eye scans a grid for. It used to share a type step with the location
332
+ and sit under a disclosure three times its size. */}
333
+ {price !== undefined && (
334
+ <Typography.Text
335
+ strong
336
+ style={{ fontSize: fontSize.lg.fontSize }}
337
+ data-testid="search-result-price"
338
+ >
339
+ {price}
340
+ </Typography.Text>
341
+ )}
342
+ {(location !== undefined || distance !== undefined) && (
343
+ <Typography.Text type="secondary">
344
+ {[location, distance].filter((v) => v !== undefined).join(" · ")}
345
+ </Typography.Text>
242
346
  )}
243
- <Flex vertical gap={spacing[1]}>
244
- <Typography.Text strong>{title}</Typography.Text>
245
- {/* The price is the strongest line on a catalogue card: it is what
246
- the eye scans a grid for. It used to share a type step with the
247
- location and sit under a disclosure three times its size. */}
248
- {price !== undefined && (
249
- <Typography.Text
250
- strong
251
- style={{ fontSize: fontSize.lg.fontSize }}
252
- data-testid="search-result-price"
253
- >
254
- {price}
255
- </Typography.Text>
256
- )}
257
- {(location !== undefined || distance !== undefined) && (
258
- <Typography.Text type="secondary">
259
- {[location, distance].filter((v) => v !== undefined).join(" · ")}
260
- </Typography.Text>
261
- )}
262
- </Flex>
263
347
  </Flex>
264
348
  );
265
349
 
@@ -272,6 +356,8 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
272
356
  styles={{ body: { padding: spacing[3] } }}
273
357
  >
274
358
  <Flex vertical gap={spacing[2]}>
359
+ {/* A SIBLING of the anchor, never a child — see `CardPhotoStrip`. */}
360
+ <CardPhotoStrip photos={photos} title={title} />
275
361
  {href === undefined ? (
276
362
  body
277
363
  ) : (