@bearmenu/ui 0.8.8 → 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 (206) 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-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Structural mirror of the openapi `Dish` / `Drink` schemas, decoupled from any
3
+ * one app's generated types. FE, admin and post-generator map their domain
4
+ * shapes into these before passing data to the menu-hub surfaces.
5
+ *
6
+ * Same contract as `place-types.ts` and `event-types.ts`: no app schemas, no
7
+ * app hooks, no i18n. Every user-facing string arrives through a `*Labels`
8
+ * object, every destination through an `href`, and every locale-sensitive
9
+ * format through an injected formatter — the DS never owns a translation key
10
+ * or an `Intl` locale.
11
+ *
12
+ * ## One set of components, two tabs
13
+ *
14
+ * Dishes and drinks are the same shape. `MenuItemPreview.kind` is the only
15
+ * discriminator, and nothing in this file branches on it — a drinks hub is a
16
+ * different view model and a different `*Labels` object, never a second set of
17
+ * components. Anything that would need a `if (kind === 'drink')` inside a
18
+ * component belongs in the caller's mapper instead.
19
+ */
20
+
21
+ import type { ComponentType, CSSProperties, ReactNode } from 'react'
22
+
23
+ import type { MenuSizeAndPrice, PlacePreview } from './place-types'
24
+
25
+ export type { MenuSizeAndPrice } from './place-types'
26
+
27
+ /**
28
+ * The shape every menu surface's `linkComponent` must accept.
29
+ *
30
+ * One type rather than a clone per component: these surfaces nest each other —
31
+ * a family card renders a `<MenuItem>`, a spotlight renders a signature block —
32
+ * and a caller passes ONE `next/link` down through all of them. Subtly
33
+ * different clones made those hand-offs fail to typecheck for no design reason.
34
+ */
35
+ export type MenuLinkProps = {
36
+ href: string
37
+ className?: string
38
+ 'aria-label'?: string
39
+ 'aria-current'?: 'page'
40
+ /** Intent tiles paint their hue on the link element itself. */
41
+ style?: CSSProperties
42
+ children?: ReactNode
43
+ }
44
+
45
+ export type MenuLinkComponent = ComponentType<MenuLinkProps>
46
+
47
+ /**
48
+ * A dish or a drink as every hub surface consumes it.
49
+ *
50
+ * `mentions` is the census count of review mentions and is populated on every
51
+ * item in the catalogue — it is the ranking signal the hub is built on. Do not
52
+ * confuse it with `review_count`, which counts full review objects and is zero
53
+ * for most items; a surface that ranks on `review_count` renders an empty page.
54
+ */
55
+ export type MenuItemPreview = {
56
+ id: string
57
+ slug: string
58
+ name: string
59
+ kind: 'dish' | 'drink'
60
+ description?: string
61
+ /** API category code (pizza, pasta, cocktail, non-alcoholic, …). Localised by the caller. */
62
+ category_slug?: string
63
+ sizes_and_prices?: MenuSizeAndPrice[]
64
+ /** Censused review mentions. The hub's ranking signal. */
65
+ mentions?: number
66
+ /** Full review objects citing this item. Sparse — not a ranking signal. */
67
+ review_count?: number
68
+ /** 0–100 recommendation score. */
69
+ score?: number
70
+ /** The venue serving it. Menu items are always venue-scoped. */
71
+ place?: MenuItemPlaceRef
72
+ /**
73
+ * Pre-resolved photo URL. Menu-item photography is AI-generated and hidden in
74
+ * production, so hub surfaces draw a typographic `<MenuFieldTile>` instead
75
+ * and leave this unset. It exists for the surfaces that legitimately show one
76
+ * (concept detail) and for consumers outside the consumer app.
77
+ */
78
+ photo_url?: string
79
+ }
80
+
81
+ /** The venue attribution carried on a menu item. */
82
+ export type MenuItemPlaceRef = {
83
+ slug: string
84
+ name: string
85
+ /** Pre-resolved venue thumbnail. Venue photography is real; item photography is not. */
86
+ thumbnail_url?: string
87
+ }
88
+
89
+ /**
90
+ * A dish or drink concept — one item across every kitchen that makes it, under
91
+ * whatever name each of them uses. The page's keystone, and the one thing the
92
+ * catalogue can say that a single menu cannot.
93
+ */
94
+ export type MenuConceptPreview = {
95
+ slug: string
96
+ /** Canonical concept name, e.g. "Margherita". */
97
+ title: string
98
+ kind: 'dish' | 'drink'
99
+ /** Venues serving it. */
100
+ venue_count?: number
101
+ price_low?: number
102
+ price_high?: number
103
+ /** Distinct names it appears under across those menus. */
104
+ alias_count?: number
105
+ /** Summed censused mentions across every venue's version. */
106
+ mentions?: number
107
+ rows?: MenuConceptRow[]
108
+ }
109
+
110
+ /** One venue's version of a concept, as drawn by `<ConceptCompare>`. */
111
+ export type MenuConceptRow = {
112
+ place_slug: string
113
+ place_name: string
114
+ /** What this kitchen calls it on its own menu. */
115
+ alias: string
116
+ price: number
117
+ mentions?: number
118
+ rating?: number
119
+ /** Venue type line, e.g. "pub". Localised by the caller. */
120
+ place_type?: string
121
+ /** Neighbourhood, e.g. "Centru". */
122
+ place_area?: string
123
+ /**
124
+ * Pre-resolved VENUE photograph.
125
+ *
126
+ * The row is place-led: five restaurants that disagree about a name, not five
127
+ * bars of similar length. A price bar was drawn here first and it measured
128
+ * the one thing already written beside it in words, while the venue — the
129
+ * thing you are actually choosing between — had no image at all.
130
+ */
131
+ place_thumbnail_url?: string
132
+ open_now?: boolean
133
+ /** Marks the row the surface should lead with (cheapest, or most named). */
134
+ featured?: boolean
135
+ /** Portion, already formatted with its unit, e.g. "260g". */
136
+ portion_label?: string
137
+ }
138
+
139
+ /**
140
+ * A browsable grouping of venues — a cuisine on the dishes tab, a drink family
141
+ * on the drinks tab. Both are sets of VENUES, never sets of items: cuisine
142
+ * exists only on places, so a card counting dishes cannot be built.
143
+ */
144
+ export type MenuFamilyPreview = {
145
+ slug: string
146
+ /** Display name, already localised by the caller. */
147
+ name: string
148
+ /** Venues in this family. */
149
+ venue_count: number
150
+ /**
151
+ * Facet chips. Counts are VENUES, not items — they come from the places
152
+ * facet narrowed by this family. The drinks tab has no equivalent facet, so
153
+ * its chips ship without counts; `count` is optional for that reason.
154
+ */
155
+ chips?: MenuFamilyChip[]
156
+ /** Price band across the family's venues. */
157
+ price_low?: number
158
+ price_high?: number
159
+ price_median?: number
160
+ /** Pre-resolved venue photos for the mosaic. Venue photography only. */
161
+ venue_photo_urls?: string[]
162
+ }
163
+
164
+ export type MenuFamilyChip = {
165
+ slug: string
166
+ label: string
167
+ /** Venue count. Absent where no facet backs the chip. */
168
+ count?: number
169
+ }
170
+
171
+ /**
172
+ * A venue as the hub's venue-scoped surfaces consume it: the place itself plus
173
+ * the pre-resolved bits a hub row needs and `PlacePreview` does not carry —
174
+ * which signature to lead with, and where the row points.
175
+ */
176
+ export type MenuVenuePreview = {
177
+ place: PlacePreview
178
+ href: string
179
+ /**
180
+ * The signature to draw. Callers pick it from `reviews_digest.must_try`
181
+ * rather than the surface re-deriving the rule on every row.
182
+ */
183
+ signature?: MenuVenueSignature
184
+ /** Closing time as an already-formatted wall clock, e.g. "23:00". */
185
+ closes_at_label?: string
186
+ }
187
+
188
+ /**
189
+ * The resolved signature on a venue row.
190
+ *
191
+ * It carries a whole `MenuItemPreview` rather than a flattened name and price,
192
+ * because every surface that draws one renders `<MenuItem>` — the same row a
193
+ * venue's own menu uses — and that component takes an item. Flattening it here
194
+ * was what let the hub's version drift into a different-looking row.
195
+ */
196
+ export type MenuVenueSignature = {
197
+ item: MenuItemPreview
198
+ /** The reviewers' reason, from the digest. Rendered in place of the description. */
199
+ why?: string
200
+ href?: string
201
+ /** The digest called this the venue's signature rather than merely frequent. */
202
+ isSignature?: boolean
203
+ }
204
+
205
+ /**
206
+ * An intent tile — a family plus a constraint, resolving to a category page.
207
+ *
208
+ * Only about thirty-two of the thirty-eight cuisines have a page, so `href` is
209
+ * required: a tile that cannot resolve to a real destination is not rendered
210
+ * rather than linking nowhere.
211
+ */
212
+ export type MenuIntentTile = {
213
+ slug: string
214
+ label: string
215
+ /** Supporting line, e.g. "247 kitchens". Already formatted by the caller. */
216
+ caption?: string
217
+ href: string
218
+ /**
219
+ * Finished CSS gradient for the tile's ground, e.g.
220
+ * `linear-gradient(150deg, …)`.
221
+ *
222
+ * A gradient rather than a token name, for the same reason `<MenuField>`
223
+ * takes one: the hue tables are app-owned, and a component referencing
224
+ * `var(--h-pizza)` renders an invisible tile in any consumer that does not
225
+ * happen to define that variable. A missing gradient falls back to `bg-muted`
226
+ * — visible and obviously unstyled, rather than white text on white.
227
+ */
228
+ gradient?: string
229
+ /** Icon node, resolved by the caller — the DS does not pick icons for it. */
230
+ icon?: ReactNode
231
+ }
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
+
421
+ /** Formatters injected into every menu surface. The DS owns no `Intl` locale. */
422
+ export type MenuFormatters = {
423
+ /** 24 → "24 lei". */
424
+ price: (value: number) => string
425
+ /** 1717 → "1,717". */
426
+ number: (value: number) => string
427
+ }
428
+
429
+ /** Strings shared by every menu-hub surface. */
430
+ export type MenuHubLabels = {
431
+ /** Mention-count badge. Pass a translator that handles ICU pluralization. */
432
+ mentions: (count: number) => string
433
+ /** Venue-count line. Pass a translator that handles ICU pluralization. */
434
+ venues: (count: number) => string
435
+ /** "See all" affordance on a section header. */
436
+ seeAll: string
437
+ /** Badge on a must-try row the digest called `signature`. */
438
+ signature: string
439
+ openNow: string
440
+ /** Prefixes a closing time, e.g. "until". */
441
+ until: string
442
+ }
@@ -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>
@@ -76,12 +77,20 @@ const UTENSILS_TIMES = [0, 0.18, 0.72, 1];
76
77
  */
77
78
  const SEPARATION = 5;
78
79
 
80
+ /**
81
+ * The fork is not centred on the crossing: its handle reaches ~13.9 units
82
+ * below (12,12) and its tines ~10.8 above, so upright it hung ~1.5 units lower
83
+ * than the knife. `FORK_LIFT` centres it in the uncrossed pose.
84
+ */
85
+ const FORK_LIFT = -1.5;
86
+
79
87
  const fork: Variants = {
80
- rest: { rotate: 0, x: 0, transition: returnTransition },
81
- active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
88
+ rest: { rotate: 0, x: 0, y: 0, transition: returnTransition },
89
+ active: { rotate: -44, x: -SEPARATION, y: FORK_LIFT, transition: returnTransition },
82
90
  play: {
83
91
  rotate: [0, 6, -50, -44],
84
92
  x: [0, 0.8, -6, -SEPARATION],
93
+ y: [0, 0, FORK_LIFT - 0.3, FORK_LIFT],
85
94
  transition: beats(UTENSILS_TIMES, 0.6),
86
95
  },
87
96
  };
@@ -96,7 +105,26 @@ const knife: Variants = {
96
105
  },
97
106
  };
98
107
 
99
- const CROSSING = { originX: "12px", originY: "12px" } as const;
108
+ const CROSSING = pivot(12, 12);
109
+
110
+ /**
111
+ * The fork's handle. Lucide leaves a gap between the handle and the neck where
112
+ * the knife crosses, so the two strokes never form a blob at the crossing. Once
113
+ * the fork swings clear the gap is just a broken fork, so the handle lengthens
114
+ * to meet the neck at (12,12) as it uncrosses and shortens again on the way
115
+ * back. Same command shape both ways, so the `d` tweens.
116
+ */
117
+ const HANDLE_CROSSED = "M2.1 21.8 8.5 15.4";
118
+ const HANDLE_CLEAR = "M2.1 21.8 12 12";
119
+
120
+ const forkHandle: Variants = {
121
+ rest: { d: HANDLE_CROSSED, transition: returnTransition },
122
+ active: { d: HANDLE_CLEAR, transition: returnTransition },
123
+ play: {
124
+ d: [HANDLE_CROSSED, HANDLE_CROSSED, HANDLE_CLEAR, HANDLE_CLEAR],
125
+ transition: beats(UTENSILS_TIMES, 0.6),
126
+ },
127
+ };
100
128
 
101
129
  /**
102
130
  * The one gesture that HOLDS: uncrossed is the active pose. ~89° of relative
@@ -113,7 +141,7 @@ export function MotionUtensils(props: MotionIconProps) {
113
141
  />
114
142
  <motion.g variants={fork} style={CROSSING}>
115
143
  <path d="m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8" />
116
- <path d="m2.1 21.8 6.4-6.3" />
144
+ <motion.path variants={forkHandle} d={HANDLE_CROSSED} />
117
145
  <path d="m19 5-7 7" />
118
146
  </motion.g>
119
147
  </MotionIcon>
@@ -153,11 +181,11 @@ const pinDot: Variants = {
153
181
  export function MotionMapPin(props: MotionIconProps) {
154
182
  return (
155
183
  <MotionIcon {...props}>
156
- <motion.g variants={pinBody} style={{ originX: "12px", originY: "22px" }}>
184
+ <motion.g variants={pinBody} style={pivot(12, 22)}>
157
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" />
158
186
  <motion.circle
159
187
  variants={pinDot}
160
- style={{ originX: "12px", originY: "10px" }}
188
+ style={pivot(12, 10)}
161
189
  fill="currentColor"
162
190
  fillOpacity={0}
163
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
  /**