@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
@@ -0,0 +1,425 @@
1
+ import { MapPin, MessageSquareQuote, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import { Skeleton } from "./skeleton";
5
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
6
+ import type { MenuItemPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
7
+ import type { TopRankEntry } from "./top-types";
8
+
9
+ /**
10
+ * TopRankingEntry — one rank, as an article entry.
11
+ *
12
+ * ## A numbered editorial list, not a grid of cards
13
+ *
14
+ * Cards imply a grid you filter, and they force every entry into the same
15
+ * height — wrong when the evidence varies, because one kitchen has a three-line
16
+ * quote and another has a sentence. A numbered list lets each entry be as long
17
+ * as its argument, which is what reads as an article, which is what ranks.
18
+ *
19
+ * ## Evidence leads, because a rank is a claim
20
+ *
21
+ * The cited review is the entry's first content, not a footnote. The dish it
22
+ * names sits under it with its price and its mention count. The old page had
23
+ * this the other way round — name, grade, photo, then evidence somewhere below
24
+ * the fold — which asked the reader to accept the position before showing them
25
+ * any reason for it.
26
+ *
27
+ * ## Two targets, the same rule as everywhere
28
+ *
29
+ * The entry goes to the venue; the signature dish opens the item drawer. Not
30
+ * implemented as a stretched overlay here, unlike `MenuKitchenList`: this entry
31
+ * is a paragraph of prose with several links inside it, so a full-bleed overlay
32
+ * would swallow them. The name and the cover are the venue's targets.
33
+ */
34
+
35
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
36
+ return <a href={href} {...props} />;
37
+ }
38
+
39
+ export type TopRankingEntryLabels = {
40
+ /** Heading over the menu rows when the venue's own group is unknown. */
41
+ signature: string;
42
+ /** Accessible name for the dish link. */
43
+ openDish: (name: string) => string;
44
+ /** Passed through to the production `<MenuItem>`. */
45
+ item: MenuItemLabels;
46
+ /**
47
+ * Opens an item's drawer. The DS owns no routing, so the caller composes it.
48
+ *
49
+ * Takes the ITEM, not its slug: a menu row is a dish or a drink and the two
50
+ * resolve through different endpoints, so the caller has to see `kind` to
51
+ * build the right link. `undefined` leaves the row inert, which is the
52
+ * text-only must-try mention with no live menu row behind it.
53
+ */
54
+ itemHref: (item: MenuItemPreview) => string | undefined;
55
+ };
56
+
57
+ export type TopRankingEntryProps = {
58
+ entry: TopRankEntry;
59
+ labels: TopRankingEntryLabels;
60
+ formatPrice: (amount: number) => string;
61
+ formatCount?: (value: number) => string;
62
+ /** The category's hue, for the menu rows' colour mark. */
63
+ markGradient?: string;
64
+ linkComponent?: MenuLinkComponent;
65
+ className?: string;
66
+ };
67
+
68
+ /**
69
+ * The scrim under the rank, name, district and grade on the phone.
70
+ *
71
+ * A literal, never `var(--scrim)`: that token is defined in the consumer app's
72
+ * stylesheet and not in this package's, so a DS component reading it renders an
73
+ * invisible scrim in Storybook, admin and the post-generator — and the failure
74
+ * only appears in the consumers nobody is looking at. Same base colour as
75
+ * `MenuFamilyCard` and `MenuFamilyBlock`, so the three are one family.
76
+ *
77
+ * The stops are in PIXELS from the block's top, not percentages of the cover.
78
+ * The cover is an aspect ratio, so its height runs 192px to 265px across the
79
+ * phone range and the name wraps to one line or two; a percentage ramp would
80
+ * put the first glyph anywhere between alpha 0.52 and 0.79 depending on the
81
+ * handset and the venue. Anchored to the fixed `pt-12` runway it is 0.72 every
82
+ * time — which is what makes the contrast floor a guarantee rather than an
83
+ * average. Against a pure-white photograph that is 7.3:1 on the name.
84
+ *
85
+ * If it ever bands on a flat bright sky, lengthen the runway (`pt-14`, 0.72
86
+ * stop to 56px). Never lower the terminal alpha — that is the guarantee.
87
+ *
88
+ * Applied unprefixed and cleared with `lg:bg-none`, NOT written as `max-lg:`.
89
+ * This project's Tailwind emits no `max-width` media queries at all, so every
90
+ * `max-lg:` class is silently dropped — no error, no style, and on this
91
+ * component that meant white type on white paper in the gap under the photo.
92
+ */
93
+ const ENTRY_COVER_SCRIM =
94
+ "bg-[linear-gradient(180deg,oklch(0.16_0.012_55_/_0)_0px,oklch(0.16_0.012_55_/_0.06)_16px,oklch(0.16_0.012_55_/_0.3)_32px,oklch(0.16_0.012_55_/_0.72)_48px,oklch(0.16_0.012_55_/_0.95)_100%)]";
95
+
96
+ export function TopRankingEntry({
97
+ entry,
98
+ labels,
99
+ formatPrice,
100
+ formatCount,
101
+ markGradient,
102
+ linkComponent: LinkComponent = DefaultLink,
103
+ className,
104
+ }: TopRankingEntryProps) {
105
+ const initial = (entry.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
106
+
107
+ return (
108
+ <article
109
+ id={entry.slug}
110
+ /* A one-column GRID on the phone so the identity block can share the
111
+ cover's cell, and the same flex row it always was from `lg` — the
112
+ desktop declarations below compute to exactly today's box. */
113
+ className={cn("grid grid-cols-1 gap-0 py-6 lg:flex lg:flex-row lg:gap-7", className)}
114
+ >
115
+ {/*
116
+ The cover sits beside the entry rather than above it: the column has the
117
+ room, and a full-width photograph per entry turns twenty-five of them
118
+ into a scroll with no argument visible at any point.
119
+
120
+ ## One box, the same on every entry
121
+
122
+ It was 300×225, and entries run roughly 300–370px tall — so the short
123
+ cover left a hole under itself on nearly all of them. Letting it stretch
124
+ to the row would close that, but then no two entries share a photo size,
125
+ and a ranking whose thumbnails all differ reads as broken rather than as
126
+ responsive. Fixed 4:5 instead: it sits at the common entry height, and
127
+ where it overshoots, the text simply ends — a much softer failure than an
128
+ image floating above a void.
129
+
130
+ `object-cover` absorbs the source ratios. Restaurant photography is
131
+ landscape, so a 4:5 centre-crop loses the sides and keeps the subject.
132
+ */}
133
+ <LinkComponent
134
+ href={entry.href}
135
+ className="col-start-1 row-start-1 block shrink-0 overflow-hidden rounded-xl transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 motion-reduce:transition-none lg:sticky lg:top-4 lg:w-[300px] lg:self-start"
136
+ >
137
+ {/* 3:2 on the phone, 4:5 from `lg`.
138
+ 4:5 is ~448px at 390 — an entry ran ~880px, so a reader met one
139
+ venue per two screens on a page whose unit of reading is the entry.
140
+ 3:2 halves the cover and, with the identity line folded into it,
141
+ brings an entry to roughly one viewport. It is also the ratio the
142
+ source photography is actually shot at, so unlike 4:5 the crop
143
+ throws almost nothing away.
144
+
145
+ The warm ground is the phone's; desktop restores its stripes. Base
146
+ first and `lg:` to override, never `max-lg:` — this project's
147
+ Tailwind emits no `max-width` media queries at all, so a `max-lg:`
148
+ class is silently dropped and the style simply never exists. */}
149
+ <span className="relative block aspect-[3/2] w-full bg-[oklch(0.21_0.012_55)] lg:aspect-[4/5] lg:bg-[repeating-linear-gradient(135deg,oklch(0.82_0.014_55)_0_10px,oklch(0.87_0.01_58)_10px_20px)]">
150
+ {entry.coverUrl ? (
151
+ // eslint-disable-next-line @next/next/no-img-element
152
+ <img
153
+ src={entry.coverUrl}
154
+ alt=""
155
+ loading="lazy"
156
+ decoding="async"
157
+ className="h-full w-full object-cover"
158
+ />
159
+ ) : (
160
+ /* Never a grey box: the warm ground with the venue's initial, the
161
+ rule `MenuFamilyBlock` states. Phone only — desktop keeps its
162
+ stripes. The stripes are a LIGHT surface in both themes, so at
163
+ this size they would be a pale rectangle under white type.
164
+ The ground stays on the parent span, so a 404 on `coverUrl`
165
+ degrades to the same surface for free. */
166
+ <span
167
+ aria-hidden
168
+ className="absolute inset-0 flex items-center justify-center lg:hidden"
169
+ >
170
+ <span className="font-serif text-[64px] font-semibold leading-none text-white/25">
171
+ {initial}
172
+ </span>
173
+ </span>
174
+ )}
175
+ </span>
176
+ </LinkComponent>
177
+
178
+ {/* `contents` on the phone so the identity block below becomes a direct
179
+ grid child and can be placed in the cover's cell. One DOM copy, not a
180
+ `hidden`/`lg:hidden` twin: this page's whole value is search, and 25
181
+ duplicated headings and venue links is not a thing to ship to avoid a
182
+ `display: contents`. */}
183
+ <div className="contents lg:block lg:min-w-0 lg:flex-1">
184
+ {/* On the phone this sits IN the photograph, bottom-anchored, and
185
+ carries its own scrim. Grid paints items in document order, so the
186
+ block covers the cover anchor with no `z-index`.
187
+
188
+ `flex-wrap` + `basis-full` on the rank is what gives the phone its
189
+ two-line stack — rank on its own line, then the name column and the
190
+ grade pill sharing the next — while desktop keeps the single
191
+ no-wrap row with the numeral out to the left. One DOM order, two
192
+ compositions, no duplicated markup. */}
193
+ <div
194
+ className={cn(
195
+ /* `relative z-[1]`: without it this block's background is a
196
+ non-positioned box's background, which paints in an earlier phase
197
+ than the cover's replaced `<img>` — so the scrim went UNDER the
198
+ photograph and the white type landed on bare image. Positioning
199
+ it lifts the whole block, background included, above the image.
200
+ Harmless at `lg`, where the two no longer overlap. */
201
+ "pointer-events-none relative z-[1] col-start-1 row-start-1 flex flex-wrap items-end gap-x-3 gap-y-0 self-end",
202
+ "rounded-b-xl px-3.5 pb-3.5 pt-12 [text-shadow:0_1px_2px_oklch(0.16_0.012_55_/_0.45)]",
203
+ ENTRY_COVER_SCRIM,
204
+ "lg:pointer-events-auto lg:flex-nowrap lg:items-start lg:gap-3 lg:rounded-none lg:bg-none lg:p-0 lg:[text-shadow:none]"
205
+ )}
206
+ >
207
+ <span
208
+ aria-hidden
209
+ className="basis-full text-[11px] font-bold leading-none tabular-nums text-white/70 lg:basis-auto lg:font-serif lg:text-[28px] lg:font-semibold lg:text-muted-foreground"
210
+ >
211
+ {entry.rank}
212
+ </span>
213
+
214
+ <div className="min-w-0 flex-1">
215
+ <h3 className="line-clamp-2 text-lg font-bold leading-tight tracking-[-0.015em] text-white lg:line-clamp-none lg:text-foreground">
216
+ <LinkComponent
217
+ href={entry.href}
218
+ /* `lg:hover:` — sticky hover on touch would leave the name
219
+ terracotta on the scrim, which measures 1.28:1. The ring
220
+ inverts for the same reason: `--ring` is a dark brown drawn
221
+ on a dark scrim. */
222
+ className="pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white lg:focus-visible:ring-ring lg:hover:text-accent"
223
+ >
224
+ {entry.name}
225
+ </LinkComponent>
226
+ </h3>
227
+ {entry.district && (
228
+ <span className="mt-0.5 flex items-center gap-1 text-[12.5px] text-white/80 lg:text-muted-foreground">
229
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
230
+ {entry.district}
231
+ </span>
232
+ )}
233
+ </div>
234
+
235
+ {/* The grade, with the band it falls in. A bare 8.7 is a number; "8.7
236
+ Excellent" is the claim the number is making. The star is the
237
+ third-party fallback for a venue the reviews have not graded.
238
+
239
+ Over the photograph it INVERTS rather than dissolving: a white
240
+ pill with dark ink, the same mode-invariant device the family
241
+ block's "Browse" pill uses. `bg-accent-subtle` could not stay —
242
+ it is near-white in one theme and near-black in the other, and
243
+ photography is not themed — and dropping the container instead
244
+ would have cost the band word, which is the half that says what
245
+ the number MEANS. */}
246
+ {entry.gradeLabel ? (
247
+ <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/95 px-2.5 py-1 backdrop-blur-[2px] lg:gap-2 lg:rounded-lg lg:bg-accent-subtle lg:px-2.5 lg:py-1.5 lg:backdrop-blur-none">
248
+ <b className="text-[15px] font-extrabold tabular-nums text-[oklch(0.21_0.012_55)] lg:text-accent">
249
+ {entry.gradeLabel}
250
+ </b>
251
+ {entry.tierLabel && (
252
+ <span className="text-[11.5px] text-[oklch(0.21_0.012_55)]/70 lg:text-muted-foreground">
253
+ {entry.tierLabel}
254
+ </span>
255
+ )}
256
+ </span>
257
+ ) : (
258
+ entry.rating !== undefined && (
259
+ /* The star fallback inverts the same way as the grade — a white
260
+ pill, deliberately quieter than a grade, because a third-party
261
+ rating is weaker evidence and should look it. Filled, since an
262
+ outlined star at this size disappears over photography. */
263
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-white/95 px-2.5 py-1 text-[13px] font-semibold tabular-nums text-[oklch(0.21_0.012_55)] backdrop-blur-[2px] lg:bg-transparent lg:px-0 lg:py-0 lg:text-foreground lg:backdrop-blur-none">
264
+ <Star
265
+ className="h-3.5 w-3.5 shrink-0 fill-[oklch(0.21_0.012_55)] lg:fill-none"
266
+ aria-hidden
267
+ />
268
+ {entry.rating}
269
+ </span>
270
+ )
271
+ )}
272
+ </div>
273
+
274
+ {/* The place caption: large enough to be the entry's argument, quiet
275
+ enough not to be its headline.
276
+ At 17px in full foreground it was the loudest block in the entry and
277
+ competed with the venue name — three lines of black serif italic
278
+ reads as a pull-quote, not as supporting prose. The size stays,
279
+ because it is what fills the column beside a 4:5 cover; the COLOUR
280
+ drops instead. Weight is the wrong lever here: the serif italic is
281
+ what makes the page read as an article. */}
282
+ {entry.editorial && (
283
+ /* The article's row gap is 0 on the phone now that the identity block
284
+ lives inside the photograph, so the paragraph owns the space under
285
+ it and gives it back at `lg`. */
286
+ <p className="mt-4 font-serif text-[17px] italic leading-[1.65] text-muted-foreground [text-wrap:pretty] lg:mt-3">
287
+ {entry.editorial}
288
+ </p>
289
+ )}
290
+
291
+ {entry.menuItems && entry.menuItems.length > 0 && (
292
+ <div className="mt-4">
293
+ <span className="block pb-1.5 text-[15px] font-bold tracking-[-0.01em]">
294
+ {entry.menuGroupLabel ?? labels.signature}
295
+ </span>
296
+ <Separator />
297
+ {/* The production `<MenuItem>`, so a dish is visibly the same object
298
+ here as on the venue's own menu and on every hub surface. The
299
+ row owns its own padding and its colour mark is drawn at
300
+ `-ml-4`, so it is not wrapped in anything that would clip it. */}
301
+ {entry.menuItems.map((item) => (
302
+ <MenuItem
303
+ key={item.id}
304
+ item={item}
305
+ labels={labels.item}
306
+ formatPrice={formatPrice}
307
+ formatCount={formatCount}
308
+ markGradient={markGradient}
309
+ href={labels.itemHref(item)}
310
+ linkLabel={labels.openDish(item.name)}
311
+ linkComponent={LinkComponent}
312
+ />
313
+ ))}
314
+ </div>
315
+ )}
316
+
317
+ {entry.evidence && (
318
+ <figure className="mt-4 border-l-[3px] border-border pl-4">
319
+ {entry.evidence.quote && (
320
+ <blockquote className="text-[13.5px] leading-relaxed text-muted-foreground [text-wrap:pretty]">
321
+ {`“${entry.evidence.quote}”`}
322
+ </blockquote>
323
+ )}
324
+ <figcaption
325
+ className={cn(
326
+ "flex items-center gap-1.5 text-[11.5px] text-muted-foreground",
327
+ entry.evidence.quote && "mt-1.5"
328
+ )}
329
+ >
330
+ <MessageSquareQuote className="h-3 w-3 shrink-0 opacity-60" aria-hidden />
331
+ {entry.evidence.count && (
332
+ <b className="font-bold tabular-nums text-foreground">{entry.evidence.count}</b>
333
+ )}
334
+ {entry.evidence.sourceLabel}
335
+ </figcaption>
336
+
337
+ {/* A `<details>`, never a drawer: its contents ship in the HTML, so
338
+ the remaining evidence is crawlable and readable with no
339
+ JavaScript. On a page whose argument IS the evidence, hiding
340
+ most of it behind a modal gives away the argument. */}
341
+ {entry.evidence.extraQuotes && entry.evidence.extraQuotes.length > 0 && (
342
+ <details className="mt-2">
343
+ <summary className="cursor-pointer text-[11.5px] font-semibold text-accent hover:underline">
344
+ {entry.evidence.extraLabel}
345
+ </summary>
346
+ <ul className="mt-2 flex flex-col gap-2">
347
+ {entry.evidence.extraQuotes.map((quote) => (
348
+ <li
349
+ key={quote}
350
+ className="text-[13px] leading-relaxed text-muted-foreground [text-wrap:pretty]"
351
+ >
352
+ {`“${quote}”`}
353
+ </li>
354
+ ))}
355
+ </ul>
356
+ </details>
357
+ )}
358
+ </figure>
359
+ )}
360
+
361
+ {/* No "see the full menu" button.
362
+ The entry already has three targets — the cover, the name, and every
363
+ dish row — and all of them lead into the venue. A fourth, sitting
364
+ under the rows as the heaviest element in the block, made the entry
365
+ read as a card with a call to action rather than as an item in an
366
+ article. The name is the way in. */}
367
+ </div>
368
+ </article>
369
+ );
370
+ }
371
+
372
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * The entry's own loading state, at the entry's footprint.
376
+ *
377
+ * The cover keeps its 3:2 on the phone and its 4:5 at 300px wide on desktop, so
378
+ * the article does not jump when the photographs land — the one reflow a long,
379
+ * scrolled page cannot afford. (This said "4:3 … 300×225" for a while after the
380
+ * cover changed underneath it, which is the failure mode a skeleton in the same
381
+ * file as its component is supposed to prevent.)
382
+ */
383
+ export function TopRankingEntrySkeleton({ className }: { className?: string }) {
384
+ return (
385
+ <div
386
+ className={cn("flex flex-col gap-0 py-6 lg:flex-row lg:gap-7", className)}
387
+ aria-hidden
388
+ >
389
+ <Skeleton className="aspect-[3/2] w-full shrink-0 rounded-xl lg:aspect-[4/5] lg:w-[300px]" />
390
+ <div className="min-w-0 flex-1">
391
+ {/* Hidden on the phone: the real entry draws the rank, name, district
392
+ and grade INSIDE the photograph, so a placeholder row here would
393
+ promise a band that never arrives and reflow on hydration. */}
394
+ <div className="hidden items-start gap-3 lg:flex">
395
+ <Skeleton className="h-7 w-6" />
396
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
397
+ <Skeleton className="h-5 w-[220px]" />
398
+ <Skeleton className="h-3 w-[100px]" />
399
+ </div>
400
+ <Skeleton className="h-8 w-[110px] rounded-lg" />
401
+ </div>
402
+ <div className="mt-4 flex flex-col gap-2 lg:mt-3">
403
+ <Skeleton className="h-4 w-full" />
404
+ <Skeleton className="h-4 w-[92%]" />
405
+ <Skeleton className="h-4 w-[64%]" />
406
+ </div>
407
+ <Skeleton className="mt-4 h-[62px] w-full rounded-lg" />
408
+ </div>
409
+ </div>
410
+ );
411
+ }
412
+
413
+ /** A run of entries — the ranking's loading state. */
414
+ export function TopRankingListSkeleton({ rows = 6 }: { rows?: number }) {
415
+ return (
416
+ <div aria-hidden>
417
+ {Array.from({ length: rows }, (_, index) => (
418
+ <div key={index}>
419
+ {index > 0 && <Separator />}
420
+ <TopRankingEntrySkeleton />
421
+ </div>
422
+ ))}
423
+ </div>
424
+ );
425
+ }
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ChefHat, Clock, LayoutList, MapPin } from "lucide-react";
3
+ import { TopMethodNote, TopRankingHero } from "./top-ranking-hero";
4
+ import { mockGradients } from "./menu-mocks";
5
+
6
+ const meta: Meta<typeof TopRankingHero> = {
7
+ title: "Top/TopRankingHero",
8
+ component: TopRankingHero,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ kicker: "RANKED · CUISINE",
12
+ title: "The best Italian restaurants in Cluj-Napoca",
13
+ lede: "Two hundred and forty-seven kitchens in the city cook Italian. These are the ones the reviews keep returning to — ranked on BearMenu grades, with the dish each is named for and what it costs today.",
14
+ updatedLabel: "Updated 6 September 2026",
15
+ metaLabel: "247 kitchens considered · 4,318 reviews read",
16
+ exits: [
17
+ { label: "Browse all 247 Italian kitchens", href: "/menu/cuisines/italian", icon: <LayoutList className="h-4 w-4" /> },
18
+ { label: "58 open now", href: "/menu/cuisines/italian?open=now", icon: <Clock className="h-4 w-4" /> },
19
+ ],
20
+ gradient: mockGradients.sweet,
21
+ icon: <ChefHat />,
22
+ },
23
+ decorators: [
24
+ (Story) => (
25
+ <div className="w-[1000px] bg-background p-4">
26
+ <Story />
27
+ </div>
28
+ ),
29
+ ],
30
+ parameters: {
31
+ docs: {
32
+ description: {
33
+ component:
34
+ "It says **when** and **how** before it says what. The trust contract is the point of `/top` — a visible Updated date, the size of the pool it chose from, a method line — and those sit where a reader looks for them, not in fine print at the foot. The two exits into the browsing tool are stated rather than implied: the ranking is the editorial pick, the intent page is everything else.",
35
+ },
36
+ },
37
+ },
38
+ };
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof TopRankingHero>;
42
+
43
+ export const Cuisine: Story = {};
44
+
45
+ /** A district takes cobblestone and a pin. Same component, different token. */
46
+ export const District: Story = {
47
+ args: {
48
+ kicker: "RANKED · DISTRICT",
49
+ title: "The best restaurants in Centru, Cluj-Napoca",
50
+ lede: "Four hundred and eighty-one places eat and drink inside the old town. These are the ones the reviews rate highest — whatever they cook.",
51
+ metaLabel: "481 places considered · 9,204 reviews read",
52
+ icon: <MapPin />,
53
+ gradient: "linear-gradient(150deg, oklch(0.72 0.03 70), oklch(0.62 0.028 64))",
54
+ },
55
+ };
56
+
57
+ export const Mobile: Story = {
58
+ decorators: [
59
+ (Story) => (
60
+ <div className="w-[390px] bg-background p-3">
61
+ <Story />
62
+ </div>
63
+ ),
64
+ ],
65
+ };
66
+
67
+ export const MethodNote: StoryObj<typeof TopMethodNote> = {
68
+ render: () => (
69
+ <TopMethodNote heading="How this is ranked.">
70
+ BearMenu grades, which weigh how many reviews a place has as well as how good they are, then
71
+ the review evidence for the dish each kitchen is named for. Prices are read from the venue's
72
+ current menu and dated. No sponsorship, no submissions.
73
+ </TopMethodNote>
74
+ ),
75
+ };
76
+