@bearmenu/ui 0.8.9 → 0.8.10

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 (178) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  5. package/dist/chunk-CLLY6XB3.js +41 -0
  6. package/dist/chunk-DTXNDLSE.js +125 -0
  7. package/dist/chunk-F6FMZJDG.js +113 -0
  8. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  9. package/dist/chunk-MZL7CGCL.js +109 -0
  10. package/dist/chunk-SY6RV476.js +48 -0
  11. package/dist/chunk-UDN7UHJW.js +60 -0
  12. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/command.d.ts +7 -7
  16. package/dist/components/concept-compare-rail.d.ts +50 -0
  17. package/dist/components/concept-compare-rail.js +5 -0
  18. package/dist/components/concept-compare.d.ts +75 -38
  19. package/dist/components/concept-compare.js +209 -133
  20. package/dist/components/concept-convergence.d.ts +41 -0
  21. package/dist/components/concept-convergence.js +53 -0
  22. package/dist/components/convergence.d.ts +25 -0
  23. package/dist/components/convergence.js +4 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-index-row.js +1 -1
  29. package/dist/components/event-list-row-wide.js +1 -1
  30. package/dist/components/event-list-row.js +2 -2
  31. package/dist/components/explainer-steps.d.ts +34 -0
  32. package/dist/components/explainer-steps.js +36 -0
  33. package/dist/components/glass-ground.d.ts +66 -0
  34. package/dist/components/glass-ground.js +3 -0
  35. package/dist/components/hub-surface.js +11 -1
  36. package/dist/components/hue-chip.d.ts +70 -0
  37. package/dist/components/hue-chip.js +3 -0
  38. package/dist/components/kitchen-dossier.d.ts +88 -0
  39. package/dist/components/kitchen-dossier.js +187 -0
  40. package/dist/components/menu-dead-end.d.ts +56 -0
  41. package/dist/components/menu-dead-end.js +49 -0
  42. package/dist/components/menu-family-block.d.ts +99 -0
  43. package/dist/components/menu-family-block.js +155 -0
  44. package/dist/components/menu-family-card.d.ts +33 -27
  45. package/dist/components/menu-family-card.js +43 -85
  46. package/dist/components/menu-field.js +92 -2
  47. package/dist/components/menu-filter-chips.d.ts +86 -0
  48. package/dist/components/menu-filter-chips.js +122 -0
  49. package/dist/components/menu-group-rows.d.ts +53 -0
  50. package/dist/components/menu-group-rows.js +67 -0
  51. package/dist/components/menu-index-header.d.ts +37 -0
  52. package/dist/components/menu-index-header.js +68 -0
  53. package/dist/components/menu-index-list.d.ts +48 -0
  54. package/dist/components/menu-index-list.js +96 -0
  55. package/dist/components/menu-intent-hero.d.ts +69 -0
  56. package/dist/components/menu-intent-hero.js +87 -0
  57. package/dist/components/menu-kitchen-list.d.ts +82 -0
  58. package/dist/components/menu-kitchen-list.js +5 -0
  59. package/dist/components/menu-mocks.d.ts +37 -2
  60. package/dist/components/menu-mocks.js +152 -27
  61. package/dist/components/menu-types.d.ts +182 -6
  62. package/dist/components/motion-icon-controls.js +2 -2
  63. package/dist/components/motion-icon-set.js +6 -6
  64. package/dist/components/motion-icon.d.ts +22 -1
  65. package/dist/components/motion-icon.js +1 -1
  66. package/dist/components/photo-ground.d.ts +64 -0
  67. package/dist/components/photo-ground.js +3 -0
  68. package/dist/components/place-about-tab.js +2 -2
  69. package/dist/components/place-details-mobile.js +2 -2
  70. package/dist/components/place-types.d.ts +2 -2
  71. package/dist/components/qa-block.d.ts +36 -0
  72. package/dist/components/qa-block.js +24 -0
  73. package/dist/components/scroll-fade.js +3 -121
  74. package/dist/components/snap-rail.d.ts +24 -1
  75. package/dist/components/snap-rail.js +3 -109
  76. package/dist/components/spinner.d.ts +2 -2
  77. package/dist/components/story.d.ts +34 -0
  78. package/dist/components/story.js +3 -0
  79. package/dist/components/text.d.ts +3 -3
  80. package/dist/components/top-comparison.d.ts +28 -0
  81. package/dist/components/top-comparison.js +76 -0
  82. package/dist/components/top-cross-link.d.ts +34 -0
  83. package/dist/components/top-cross-link.js +101 -0
  84. package/dist/components/top-ranking-entry.d.ts +53 -0
  85. package/dist/components/top-ranking-entry.js +217 -0
  86. package/dist/components/top-ranking-hero.d.ts +61 -0
  87. package/dist/components/top-ranking-hero.js +99 -0
  88. package/dist/components/top-ranking-legend.d.ts +69 -0
  89. package/dist/components/top-ranking-legend.js +124 -0
  90. package/dist/components/top-types.d.ts +109 -0
  91. package/dist/components/top-types.js +1 -0
  92. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  93. package/dist/components/venue-grade-dimensions.js +1 -1
  94. package/dist/components/weekend-panel.js +2 -2
  95. package/package.json +1 -1
  96. package/src/components/concept-compare-rail.tsx +131 -0
  97. package/src/components/concept-compare.stories.tsx +122 -54
  98. package/src/components/concept-compare.tsx +363 -196
  99. package/src/components/concept-convergence.stories.tsx +53 -0
  100. package/src/components/concept-convergence.tsx +90 -0
  101. package/src/components/convergence.stories.tsx +54 -0
  102. package/src/components/convergence.tsx +177 -0
  103. package/src/components/explainer-steps.stories.tsx +46 -0
  104. package/src/components/explainer-steps.tsx +74 -0
  105. package/src/components/glass-ground.tsx +129 -0
  106. package/src/components/hub-surface.tsx +11 -1
  107. package/src/components/hue-chip.stories.tsx +76 -0
  108. package/src/components/hue-chip.test.tsx +62 -0
  109. package/src/components/hue-chip.tsx +99 -0
  110. package/src/components/kitchen-dossier.stories.tsx +108 -0
  111. package/src/components/kitchen-dossier.tsx +322 -0
  112. package/src/components/menu-dead-end.stories.tsx +67 -0
  113. package/src/components/menu-dead-end.tsx +127 -0
  114. package/src/components/menu-family-block.stories.tsx +91 -0
  115. package/src/components/menu-family-block.tsx +314 -0
  116. package/src/components/menu-family-card.stories.tsx +43 -35
  117. package/src/components/menu-family-card.tsx +122 -142
  118. package/src/components/menu-filter-chips.stories.tsx +107 -0
  119. package/src/components/menu-filter-chips.tsx +238 -0
  120. package/src/components/menu-group-rows.stories.tsx +54 -0
  121. package/src/components/menu-group-rows.tsx +133 -0
  122. package/src/components/menu-index-header.stories.tsx +72 -0
  123. package/src/components/menu-index-header.tsx +125 -0
  124. package/src/components/menu-index-list.stories.tsx +50 -0
  125. package/src/components/menu-index-list.tsx +149 -0
  126. package/src/components/menu-intent-hero.stories.tsx +67 -0
  127. package/src/components/menu-intent-hero.tsx +189 -0
  128. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  129. package/src/components/menu-kitchen-list.test.tsx +78 -0
  130. package/src/components/menu-kitchen-list.tsx +284 -0
  131. package/src/components/menu-mocks.ts +198 -23
  132. package/src/components/menu-types.ts +189 -6
  133. package/src/components/motion-icon-controls.tsx +11 -11
  134. package/src/components/motion-icon-set.tsx +6 -5
  135. package/src/components/motion-icon.test.tsx +23 -1
  136. package/src/components/motion-icon.tsx +20 -0
  137. package/src/components/photo-ground.tsx +147 -0
  138. package/src/components/place-types.ts +2 -2
  139. package/src/components/qa-block.stories.tsx +43 -0
  140. package/src/components/qa-block.tsx +60 -0
  141. package/src/components/snap-rail.tsx +58 -15
  142. package/src/components/story.tsx +225 -0
  143. package/src/components/top-comparison.stories.tsx +78 -0
  144. package/src/components/top-comparison.tsx +151 -0
  145. package/src/components/top-cross-link.stories.tsx +84 -0
  146. package/src/components/top-cross-link.tsx +171 -0
  147. package/src/components/top-ranking-entry.stories.tsx +171 -0
  148. package/src/components/top-ranking-entry.test.tsx +105 -0
  149. package/src/components/top-ranking-entry.tsx +425 -0
  150. package/src/components/top-ranking-hero.stories.tsx +76 -0
  151. package/src/components/top-ranking-hero.tsx +218 -0
  152. package/src/components/top-ranking-legend.stories.tsx +78 -0
  153. package/src/components/top-ranking-legend.tsx +239 -0
  154. package/src/components/top-types.ts +110 -0
  155. package/src/components/venue-grade-dimensions.tsx +2 -2
  156. package/dist/chunk-THOVPZNN.js +0 -37
  157. package/dist/chunk-YS6BHKZO.js +0 -92
  158. package/dist/components/ranked-venue-list.d.ts +0 -49
  159. package/dist/components/ranked-venue-list.js +0 -87
  160. package/dist/components/signature-line-list.d.ts +0 -45
  161. package/dist/components/signature-line-list.js +0 -99
  162. package/dist/components/venue-busyness-strip.d.ts +0 -35
  163. package/dist/components/venue-busyness-strip.js +0 -37
  164. package/dist/components/venue-signature-block.d.ts +0 -39
  165. package/dist/components/venue-signature-block.js +0 -6
  166. package/dist/components/venue-spotlight.d.ts +0 -74
  167. package/dist/components/venue-spotlight.js +0 -138
  168. package/src/components/ranked-venue-list.stories.tsx +0 -80
  169. package/src/components/ranked-venue-list.tsx +0 -177
  170. package/src/components/signature-line-list.stories.tsx +0 -85
  171. package/src/components/signature-line-list.tsx +0 -136
  172. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  173. package/src/components/venue-busyness-strip.tsx +0 -64
  174. package/src/components/venue-signature-block.stories.tsx +0 -68
  175. package/src/components/venue-signature-block.tsx +0 -71
  176. package/src/components/venue-spotlight.stories.tsx +0 -141
  177. package/src/components/venue-spotlight.tsx +0 -236
  178. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -157,10 +157,8 @@ export type MenuFamilyPreview = {
157
157
  price_low?: number
158
158
  price_high?: number
159
159
  price_median?: number
160
- /** Pre-resolved venue photos for the fanned deck. Venue photography only. */
160
+ /** Pre-resolved venue photos for the mosaic. Venue photography only. */
161
161
  venue_photo_urls?: string[]
162
- /** The item this family is most named for. */
163
- signature?: MenuFamilySignature
164
162
  }
165
163
 
166
164
  export type MenuFamilyChip = {
@@ -170,9 +168,6 @@ export type MenuFamilyChip = {
170
168
  count?: number
171
169
  }
172
170
 
173
- /** A family card's featured item. Same shape as a venue's, for the same reason. */
174
- export type MenuFamilySignature = MenuVenueSignature
175
-
176
171
  /**
177
172
  * A venue as the hub's venue-scoped surfaces consume it: the place itself plus
178
173
  * the pre-resolved bits a hub row needs and `PlacePreview` does not carry —
@@ -235,6 +230,194 @@ export type MenuIntentTile = {
235
230
  icon?: ReactNode
236
231
  }
237
232
 
233
+ /**
234
+ * One figure in the intent hero — a number the reader can tap to filter by.
235
+ *
236
+ * The value arrives pre-formatted because the figures are not all the same
237
+ * quantity: "247" is a count and "58" is a count of a subset, but a caller may
238
+ * also want "4 past midnight", and a component that formatted numbers itself
239
+ * would need the locale it is forbidden to own.
240
+ */
241
+ export type MenuIntentFigure = {
242
+ key: string
243
+ /** Pre-formatted, e.g. "247". */
244
+ value: string
245
+ /** The unit, e.g. "kitchens" or "make a pizza". */
246
+ label: string
247
+ /**
248
+ * The state this figure applies, as a URL.
249
+ *
250
+ * An `href` rather than only a callback because the filter state lives in the
251
+ * query string: a link is server-rendered, crawlable, middle-clickable and
252
+ * survives with no JavaScript, and `?open=now` is an indexable state we
253
+ * actively want followed. `onSelect` remains for consumers holding the state
254
+ * in memory; `href` wins when both are set.
255
+ */
256
+ href?: string
257
+ /**
258
+ * Applies this figure as a filter without navigating. A figure with neither
259
+ * `href` nor `onSelect` renders as a static fact rather than as a control
260
+ * that does nothing — the total is usually one, since filtering to "all of
261
+ * them" is not a state.
262
+ */
263
+ onSelect?: () => void
264
+ /** The filter this figure applies is already on. */
265
+ active?: boolean
266
+ }
267
+
268
+ /**
269
+ * A filter chip on the intent page.
270
+ *
271
+ * Every chip counts KITCHENS, whatever facet backs it — a dietary chip reads
272
+ * "has a vegetarian dish" and counts venues, not dishes. Mixing venue counts
273
+ * and item counts in one row is what made the earlier version unreadable, so
274
+ * the type carries no unit: there is only ever one.
275
+ */
276
+ export type MenuFilterChip = {
277
+ key: string
278
+ label: string
279
+ /** Kitchens this chip would leave. Absent where no facet backs it. */
280
+ count?: number
281
+ active?: boolean
282
+ /**
283
+ * Zero kitchens under the current filters — dimmed, but STILL TAPPABLE.
284
+ *
285
+ * Tapping swaps it in and drops whichever active filter conflicts, with a
286
+ * line saying which. A disabled chip would leave the reader guessing why the
287
+ * thing they want is unavailable; this one answers it by being pressed.
288
+ */
289
+ empty?: boolean
290
+ /** Icon node, resolved by the caller. */
291
+ icon?: ReactNode
292
+ }
293
+
294
+ /**
295
+ * A venue row on the intent page — the page's spine.
296
+ *
297
+ * Distinct from `MenuVenuePreview` (the hub's ranked row) in one structural
298
+ * way that is the whole point of the surface: the dish carries its own `href`,
299
+ * so the row has TWO targets. The row goes to the venue; the dish name opens
300
+ * the item drawer. `RankedVenueList` wraps its entire row in one link and
301
+ * therefore cannot express this, which is why this is a separate shape rather
302
+ * than a flag on that one.
303
+ */
304
+ export type MenuKitchenPreview = {
305
+ place: PlacePreview
306
+ /** The venue page. The row's primary target. */
307
+ href: string
308
+ /** Venue type and district, composed and localised by the caller, e.g. "pizzeria · Centru". */
309
+ metaLabel?: string
310
+ /**
311
+ * "kitchen until 23:00", or "closed · opens 12:00". Composed by the caller
312
+ * because the wording differs by scope — a bar is not a kitchen.
313
+ */
314
+ hoursLabel?: string
315
+ dish?: MenuKitchenDish
316
+ /** Pre-formatted, e.g. "62 lei". */
317
+ priceLabel?: string
318
+ }
319
+
320
+ /** The dish a kitchen row leads with. The row's second target. */
321
+ export type MenuKitchenDish = {
322
+ name: string
323
+ /**
324
+ * Opens the item drawer. Omit for an inert dish line — a must-try the
325
+ * reviews named that has no live menu row behind it.
326
+ */
327
+ href?: string
328
+ /** Kicker over the name: "MOST NAMED HERE", or "WHY IT MATCHES" when filtered. */
329
+ kicker: string
330
+ /** Under the name: "17 reviews name it", or "mid-range · 300g" when matched. */
331
+ meta?: string
332
+ /**
333
+ * This row is answering a filter rather than volunteering its most-named
334
+ * dish. Draws the match mark and tints the block, so a reader scanning a
335
+ * filtered list can see that every row changed its reason for being there.
336
+ */
337
+ matched?: boolean
338
+ /** The filter it matched, e.g. "vegetarian". */
339
+ matchLabel?: string
340
+ }
341
+
342
+ /**
343
+ * One row in a browse index — the complete set, at a glance.
344
+ *
345
+ * The index is the page's whole SEO job: it carries a crawlable link to every
346
+ * destination the city has, which is what frees the richer blocks below it to
347
+ * be progressive. So it holds nothing but what a reader needs to choose — a
348
+ * name, a count and a colour.
349
+ */
350
+ export type MenuIndexEntry = {
351
+ slug: string
352
+ name: string
353
+ count: number
354
+ href: string
355
+ /** Finished CSS gradient for the mark. Falls back to the border colour. */
356
+ gradient?: string
357
+ }
358
+
359
+ /**
360
+ * A family on the cuisines index — the hub's card, opened out sideways.
361
+ *
362
+ * Deliberately thin. The reader here is choosing a cuisine, not a venue, and
363
+ * every field the block does NOT carry is a decision the next page owns.
364
+ */
365
+ export type MenuFamilyBlockPreview = {
366
+ slug: string
367
+ name: string
368
+ venueCount: number
369
+ /** Facet chips. Labels only — the block never prints their counts. */
370
+ chips?: MenuFamilyChip[]
371
+ /** Composed price band, e.g. "22–86 lei". */
372
+ priceBandLabel?: string
373
+ /** Venue photography only — item photography is AI-generated and never shown. */
374
+ photoUrls?: string[]
375
+ /**
376
+ * Venues, as kitchen rows. The block draws every one it is given — the caller
377
+ * decides how many, because how much of a claim the block makes is the page's
378
+ * decision, not the component's.
379
+ *
380
+ * The same `MenuKitchenPreview` the intent page's list takes, rendered by the
381
+ * same component — a family block that grew its own venue row would be the
382
+ * fourth near-identical row in the package, and they drift the moment one of
383
+ * them gets a fix.
384
+ */
385
+ venues?: MenuKitchenPreview[]
386
+ /** The family's own page. */
387
+ href: string
388
+ }
389
+
390
+ /**
391
+ * One "loosen this" row on a dead end — a filter, and what dropping it returns.
392
+ *
393
+ * The count is the point: "drop Open now" is a guess, "+8 kitchens" is an
394
+ * answer, and a dead end that cannot say which filter costs the most is just
395
+ * an empty state with extra steps.
396
+ */
397
+ export type MenuLoosenOption = {
398
+ key: string
399
+ /** e.g. 'Drop "Open now"'. */
400
+ label: string
401
+ /** e.g. "+8 kitchens". Pre-formatted. */
402
+ gainLabel: string
403
+ /**
404
+ * The state without this filter, as a URL.
405
+ *
406
+ * An `href` because dropping a filter IS a navigation wherever the filter set
407
+ * lives in the query string — which makes it server-renderable, shareable and
408
+ * undoable with the back button. `onSelect` remains for consumers holding the
409
+ * state in memory; `href` wins when both are set.
410
+ */
411
+ href?: string
412
+ onSelect?: () => void
413
+ }
414
+
415
+ /** One question and its answer. Static prose — never an accordion; see `<QaBlock>`. */
416
+ export type QaItem = {
417
+ question: string
418
+ answer: string
419
+ }
420
+
238
421
  /** Formatters injected into every menu surface. The DS owns no `Intl` locale. */
239
422
  export type MenuFormatters = {
240
423
  /** 24 → "24 lei". */
@@ -14,7 +14,7 @@
14
14
  import * as React from "react";
15
15
  import { motion, type Variants } from "framer-motion";
16
16
 
17
- import { MotionIcon, beats, returnTransition, type MotionIconProps } from "./motion-icon";
17
+ import { MotionIcon, beats, pivot, returnTransition, type MotionIconProps } from "./motion-icon";
18
18
 
19
19
  // ─── Heart — favorite. Squash, then inflate (deform) ──────────────────────────
20
20
 
@@ -41,7 +41,7 @@ export function MotionHeart(props: MotionIconProps) {
41
41
  <MotionIcon {...props}>
42
42
  <motion.path
43
43
  variants={heartBody}
44
- style={{ originX: "12px", originY: "13px" }}
44
+ style={pivot(12, 13)}
45
45
  fill="currentColor"
46
46
  fillOpacity={0}
47
47
  d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"
@@ -71,7 +71,7 @@ export function MotionBookmark(props: MotionIconProps) {
71
71
  <MotionIcon {...props}>
72
72
  <motion.path
73
73
  variants={bookmarkBody}
74
- style={{ originX: "12px", originY: "21px" }}
74
+ style={pivot(12, 21)}
75
75
  fill="currentColor"
76
76
  fillOpacity={0}
77
77
  d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"
@@ -144,14 +144,14 @@ const found: Variants = {
144
144
  export function MotionSearch(props: MotionIconProps) {
145
145
  return (
146
146
  <MotionIcon {...props}>
147
- <motion.g variants={lens} style={{ originX: "21px", originY: "21px" }}>
147
+ <motion.g variants={lens} style={pivot(21, 21)}>
148
148
  <circle cx="11" cy="11" r="8" />
149
149
  <path d="m21 21-4.34-4.34" />
150
150
  {/* Something turns up in the glass on the return stroke. */}
151
151
  <motion.circle
152
152
  variants={found}
153
153
  opacity={0}
154
- style={{ originX: "11px", originY: "11px" }}
154
+ style={pivot(11, 11)}
155
155
  fill="currentColor"
156
156
  cx="11"
157
157
  cy="11"
@@ -309,7 +309,7 @@ const crescent: Variants = {
309
309
  * cross-dissolve into visual mush at 20px.
310
310
  */
311
311
  export function MotionTheme(props: MotionIconProps) {
312
- const origin = { originX: "12px", originY: "12px" } as const;
312
+ const origin = pivot(12, 12);
313
313
  return (
314
314
  <MotionIcon {...props}>
315
315
  <motion.g variants={rays} style={origin}>
@@ -348,7 +348,7 @@ const chevron: Variants = {
348
348
  export function MotionChevron(props: MotionIconProps) {
349
349
  return (
350
350
  <MotionIcon {...props}>
351
- <motion.path variants={chevron} style={{ originX: "12px", originY: "12px" }} d="m6 9 6 6 6-6" />
351
+ <motion.path variants={chevron} style={pivot(12, 12)} d="m6 9 6 6 6-6" />
352
352
  </MotionIcon>
353
353
  );
354
354
  }
@@ -386,7 +386,7 @@ const barLeft = pauseBar(5);
386
386
  const barRight = pauseBar(-5);
387
387
 
388
388
  export function MotionPlayPause(props: MotionIconProps) {
389
- const origin = { originX: "12px", originY: "12px" } as const;
389
+ const origin = pivot(12, 12);
390
390
  return (
391
391
  <MotionIcon {...props}>
392
392
  <motion.path
@@ -433,7 +433,7 @@ const slash: Variants = {
433
433
  };
434
434
 
435
435
  export function MotionEye(props: MotionIconProps) {
436
- const origin = { originX: "12px", originY: "12px" } as const;
436
+ const origin = pivot(12, 12);
437
437
  return (
438
438
  <MotionIcon {...props}>
439
439
  <motion.path
@@ -471,7 +471,7 @@ export function MotionStar(props: MotionIconProps) {
471
471
  <MotionIcon {...props}>
472
472
  <motion.path
473
473
  variants={starBody}
474
- style={{ originX: "12px", originY: "12px" }}
474
+ style={pivot(12, 12)}
475
475
  fill="currentColor"
476
476
  fillOpacity={0}
477
477
  d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
@@ -541,7 +541,7 @@ const refreshRing: Variants = {
541
541
  export function MotionRefresh(props: MotionIconProps) {
542
542
  return (
543
543
  <MotionIcon {...props}>
544
- <motion.g variants={refreshRing} style={{ originX: "12px", originY: "12px" }}>
544
+ <motion.g variants={refreshRing} style={pivot(12, 12)}>
545
545
  <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
546
546
  <path d="M21 3v5h-5" />
547
547
  <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
@@ -18,6 +18,7 @@ import { motion, type Variants } from "framer-motion";
18
18
  import {
19
19
  MotionIcon,
20
20
  beats,
21
+ pivot,
21
22
  returnTransition,
22
23
  type MotionIconProps,
23
24
  } from "./motion-icon";
@@ -54,10 +55,10 @@ export function MotionHouse(props: MotionIconProps) {
54
55
  <MotionIcon {...props}>
55
56
  <motion.path
56
57
  variants={houseRoof}
57
- style={{ originX: "12px", originY: "10px" }}
58
+ style={pivot(12, 10)}
58
59
  d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10"
59
60
  />
60
- <motion.g variants={houseWalls} style={{ originX: "12px", originY: "21px" }}>
61
+ <motion.g variants={houseWalls} style={pivot(12, 21)}>
61
62
  <path d="M21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V10" />
62
63
  <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
63
64
  </motion.g>
@@ -104,7 +105,7 @@ const knife: Variants = {
104
105
  },
105
106
  };
106
107
 
107
- const CROSSING = { originX: "12px", originY: "12px" } as const;
108
+ const CROSSING = pivot(12, 12);
108
109
 
109
110
  /**
110
111
  * The fork's handle. Lucide leaves a gap between the handle and the neck where
@@ -180,11 +181,11 @@ const pinDot: Variants = {
180
181
  export function MotionMapPin(props: MotionIconProps) {
181
182
  return (
182
183
  <MotionIcon {...props}>
183
- <motion.g variants={pinBody} style={{ originX: "12px", originY: "22px" }}>
184
+ <motion.g variants={pinBody} style={pivot(12, 22)}>
184
185
  <path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
185
186
  <motion.circle
186
187
  variants={pinDot}
187
- style={{ originX: "12px", originY: "10px" }}
188
+ style={pivot(12, 10)}
188
189
  fill="currentColor"
189
190
  fillOpacity={0}
190
191
  cx="12"
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { render } from "@testing-library/react";
3
3
  import * as React from "react";
4
4
 
5
- import { MotionIcon } from "./motion-icon";
5
+ import { MotionIcon, pivot } from "./motion-icon";
6
6
 
7
7
  /**
8
8
  * The primitive drives its gesture through animation controls rather than a
@@ -157,3 +157,25 @@ describe("MotionIcon", () => {
157
157
  expect(variantsStarted).toEqual(["play"]);
158
158
  });
159
159
  });
160
+
161
+ /**
162
+ * Framer Motion puts `transform-box: fill-box` on every SVG element it
163
+ * transforms, so a pixel origin is read against that element's OWN bounding
164
+ * box, not the icon's viewBox. The chevron's box starts at (6, 9); "12px 12px"
165
+ * therefore meant (18, 21) and the arrow slid sideways as it flipped. The
166
+ * helper's whole job is the one property that makes viewBox pixels mean what
167
+ * they say — and nothing errors if it goes missing, so it is pinned here.
168
+ */
169
+ describe("pivot", () => {
170
+ it("anchors the origin to the viewBox, not the part's own box", () => {
171
+ expect(pivot(12, 12)).toEqual({
172
+ originX: "12px",
173
+ originY: "12px",
174
+ transformBox: "view-box",
175
+ });
176
+ });
177
+
178
+ it("carries a pivot that is deliberately off the part, like a pin's tip", () => {
179
+ expect(pivot(12, 22)).toMatchObject({ originY: "22px", transformBox: "view-box" });
180
+ });
181
+ });
@@ -62,6 +62,26 @@ export function beats(times: number[], duration = 0.62): Transition {
62
62
  /** Leaving is not a performance — it returns the glyph, firmly and fast. */
63
63
  export const returnTransition: Transition = { type: "spring", stiffness: 320, damping: 26 };
64
64
 
65
+ /**
66
+ * A moving part's pivot, in the icon's 24-unit VIEWBOX coordinates.
67
+ *
68
+ * Every part in this family wrote `style={{ originX: "12px", originY: "12px" }}`
69
+ * meaning "the centre of the icon" — and every one of them was pivoting
70
+ * somewhere else. Framer Motion sets `transform-box: fill-box` on any SVG
71
+ * element it transforms, which makes `transform-origin` relative to that
72
+ * element's OWN bounding box, not the viewBox. The chevron `m6 9 6 6 6-6` has
73
+ * its box at (6, 9), so "12px 12px" resolved to user-space (18, 21): a 180°
74
+ * flip around a point outside the arrow, which is why it visibly slid as it
75
+ * turned. A house's walls pivoting at its floor, a pin at its tip, a lens at
76
+ * its centre — all of them missed by the same offset for the same reason.
77
+ *
78
+ * `transform-box: view-box` is the single switch that makes px origins mean
79
+ * what they say. Declared once here so the next icon cannot get it wrong.
80
+ */
81
+ export function pivot(x: number, y: number) {
82
+ return { originX: `${x}px`, originY: `${y}px`, transformBox: "view-box" } as const;
83
+ }
84
+
65
85
  type PoseValues = Record<string, number>;
66
86
 
67
87
  /**
@@ -0,0 +1,147 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * PhotoGround — venue photography as the ground of a card.
5
+ *
6
+ * Three cards are built on photographs rather than on panels: the hub's family
7
+ * card, the cuisines index's family block and the concept card. Each had grown
8
+ * its own copy of the same stack — a dark ground, a grid of `<img>`s with an
9
+ * initial for the missing ones, a hue layer, an overlay — and the one rule that
10
+ * matters for all of them (the tint has to be `mix-blend-color`, the ground
11
+ * has to be a fixed literal) was written three times. This is the stack, once.
12
+ *
13
+ * ## What it owns and what it leaves to the card
14
+ *
15
+ * It owns the photographs and their colour: the ground, the tiles, the fallback
16
+ * initial, the family's hue and an optional uniform dark overlay. It does NOT
17
+ * own the glass — the scrims and frosted bands that carry a card's type run in
18
+ * a different direction on every card (down the family block's phone, across
19
+ * its desktop, in from both ends of the concept card) and are the card's own.
20
+ *
21
+ * ## Two layouts
22
+ *
23
+ * `mosaic` tiles every photograph into a grid the caller shapes; `stack` shows
24
+ * ONE of them, `active`, and cross-fades to the next — a card whose ground
25
+ * follows a swipe. Same slots, same fallback, same tint either way.
26
+ *
27
+ * ## Why the ground is a literal, and why the tint is `color`
28
+ *
29
+ * The ground is the backdrop a blend or blur samples before a lazy image lands
30
+ * and the tile an empty slot shows, so it must not change with the theme —
31
+ * photography is not themed, and a card that flashed paper-white behind its
32
+ * photographs in light mode was wrong in exactly the mode most people use.
33
+ * `mix-blend-mode: color` takes hue and chroma from the tint layer and
34
+ * luminance from the photograph, so the room keeps its structure and the
35
+ * family keeps its colour; a plain overlay would have muddied both.
36
+ */
37
+
38
+ /** The ground under the photography. Fixed, and deliberately not a token. */
39
+ export const PHOTO_GROUND = "bg-[oklch(0.21_0.012_55)]";
40
+
41
+ /** The uniform dark overlay a card may put over every photograph. */
42
+ const OVERLAY = "absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.5)]";
43
+
44
+ export type PhotoGroundProps = {
45
+ /** One per slot. An absent one draws the slot's initial on the ground. */
46
+ photos: (string | undefined)[];
47
+ /** The fallback glyph per slot — a venue's first letter. One is reused for all. */
48
+ initials: string | string[];
49
+ /** `mosaic` tiles every slot; `stack` shows `active` and cross-fades. */
50
+ mode?: "mosaic" | "stack";
51
+ /** `stack` only: which slot is showing. */
52
+ active?: number;
53
+ /** `mosaic` only: the grid template, e.g. `"grid-cols-2 grid-rows-2 lg:grid-cols-3"`. */
54
+ mosaicClassName?: string;
55
+ /** `mosaic` only: slots from this index on are desktop-only tiles. */
56
+ phoneTiles?: number;
57
+ /** Finished CSS gradient — the family's colour, laid over the photographs. */
58
+ gradient?: string;
59
+ /** A uniform dark overlay on every photograph. */
60
+ overlay?: boolean;
61
+ /** Extra classes on the overlay, e.g. a hover that lifts it. */
62
+ overlayClassName?: string;
63
+ /** How eagerly the first photograph loads. `stack` grounds are above the fold. */
64
+ loading?: "lazy" | "eager";
65
+ className?: string;
66
+ };
67
+
68
+ export function PhotoGround({
69
+ photos,
70
+ initials,
71
+ mode = "mosaic",
72
+ active = 0,
73
+ mosaicClassName,
74
+ phoneTiles,
75
+ gradient,
76
+ overlay,
77
+ overlayClassName,
78
+ loading = "lazy",
79
+ className,
80
+ }: PhotoGroundProps) {
81
+ const initialOf = (index: number) =>
82
+ typeof initials === "string" ? initials : (initials[index] ?? initials[0] ?? "");
83
+ const hasAnyPhoto = photos.some(Boolean);
84
+
85
+ const slot = (index: number, extra?: string) => {
86
+ const src = photos[index];
87
+ return (
88
+ <span key={index} className={cn("relative block overflow-hidden", extra)}>
89
+ {src ? (
90
+ // eslint-disable-next-line @next/next/no-img-element
91
+ <img
92
+ src={src}
93
+ alt=""
94
+ loading={index === 0 ? loading : "lazy"}
95
+ decoding="async"
96
+ className="absolute inset-0 h-full w-full object-cover"
97
+ />
98
+ ) : (
99
+ /* Never a grey box: the ground with the venue's initial. A missing
100
+ photograph is not a broken image, and drawing one as a placeholder
101
+ makes a real city look unfinished. */
102
+ <span className="absolute inset-0 flex items-center justify-center">
103
+ <span className="font-serif text-[64px] font-semibold leading-none text-white/15">
104
+ {initialOf(index)}
105
+ </span>
106
+ </span>
107
+ )}
108
+ </span>
109
+ );
110
+ };
111
+
112
+ return (
113
+ <span aria-hidden className={cn("absolute inset-0 block", PHOTO_GROUND, className)}>
114
+ {mode === "mosaic" ? (
115
+ <span className={cn("absolute inset-0 grid gap-px", mosaicClassName)}>
116
+ {photos.map((_, index) =>
117
+ slot(index, phoneTiles !== undefined && index >= phoneTiles ? "hidden lg:block" : undefined)
118
+ )}
119
+ </span>
120
+ ) : (
121
+ /* Stacked, not swapped, so the change is a cross-fade and not a flash
122
+ of ground between two images. */
123
+ photos.map((_, index) =>
124
+ slot(
125
+ index,
126
+ cn(
127
+ "absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none",
128
+ index === active ? "opacity-100" : "opacity-0"
129
+ )
130
+ )
131
+ )
132
+ )}
133
+
134
+ {/* The family's colour, one layer over the whole ground. Skipped when
135
+ there is nothing to colour — a flat tint over the bare ground reads as
136
+ a mistake rather than as a family. */}
137
+ {gradient && hasAnyPhoto && (
138
+ <span
139
+ className="absolute inset-0 opacity-[0.85] mix-blend-color"
140
+ style={{ backgroundImage: gradient }}
141
+ />
142
+ )}
143
+
144
+ {overlay && <span className={cn(OVERLAY, overlayClassName)} />}
145
+ </span>
146
+ );
147
+ }
@@ -33,7 +33,7 @@ export type PlacePreview = {
33
33
  busyness?: Array<{ hour: number; score: number }>
34
34
  /**
35
35
  * The review distillation. Drives every menu-hub surface that speaks for a
36
- * venue — `<VenueSpotlight>`, `<RankedVenueList>`, `<SignatureLineList>` —
36
+ * venue — `<KitchenDossier>`, `<MenuKitchenList>`, `<OpenNowList>` —
37
37
  * because the signature dish, the pull-quotes and the graded dimensions all
38
38
  * live here rather than on the menu itself.
39
39
  */
@@ -158,7 +158,7 @@ export type PlaceReviewsDigest = {
158
158
  tagline?: CitedText
159
159
  verdict?: CitedText
160
160
  caveat?: CitedText
161
- /** 1–3 standout positives. The pull-quotes on `<VenueSpotlight>`. */
161
+ /** 1–3 standout positives. The pull-quotes on `<KitchenDossier>`. */
162
162
  highlights?: CitedText[]
163
163
  must_try?: PlaceMustTry[]
164
164
  /** Vibe codes (cozy, quiet, family_friendly, …). Localised by the caller. */
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QaBlock } from "./qa-block";
3
+ import { mockQa } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof QaBlock> = {
6
+ title: "Menu/QaBlock",
7
+ component: QaBlock,
8
+ tags: ["autodocs"],
9
+ args: { items: mockQa, heading: "Questions people ask" },
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[1100px] bg-background p-4">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "Questions answered from the numbers, as static prose — **never an accordion**. This block is the visible half of the page's `FAQPage` markup, and structured data must mirror content the reader can actually see; collapsing it hides every answer from the crawler's first paint and buys nothing, since three short answers are cheaper to scroll past than to expand. The caller renders the JSON-LD from the same array it passes here, so the markup and the visible answers cannot drift.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof QaBlock>;
29
+
30
+ export const Default: Story = {};
31
+
32
+ /** Under a page that already supplies its own section heading. */
33
+ export const NoHeading: Story = { args: { heading: undefined } };
34
+
35
+ export const Mobile: Story = {
36
+ decorators: [
37
+ (Story) => (
38
+ <div className="w-[390px] bg-background p-3">
39
+ <Story />
40
+ </div>
41
+ ),
42
+ ],
43
+ };