@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,218 @@
1
+ import { ArrowRight, CalendarCheck, Info, LayoutList, Trophy } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
4
+ import type { TopExit } from "./top-types";
5
+
6
+ /**
7
+ * TopRankingHero — the head of an editorial ranking.
8
+ *
9
+ * ## It says when, and it says how, before it says what
10
+ *
11
+ * The trust contract is the point of `/top`: a visible "Updated" date, the size
12
+ * of the pool it chose from, and a method line. Those are not fine print here —
13
+ * they are the reason a reader believes the ten, and they sit where a reader
14
+ * looks for them rather than at the foot of the page.
15
+ *
16
+ * ## The exits are stated, not implied
17
+ *
18
+ * Two links out to the browsing tool — "Browse all 247 Italian kitchens" and
19
+ * "58 open now". The ranking is the editorial pick; the intent page is
20
+ * everything else, and the page says so rather than leaving a reader who wants
21
+ * the directory to bounce. Placed here, in the list, and in the Q&A: three, and
22
+ * never a sticky bar. A sticky CTA on an article is an advert, and this page's
23
+ * job is to be read to the end.
24
+ */
25
+
26
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
27
+ return <a href={href} {...props} />;
28
+ }
29
+
30
+ export type TopRankingHeroProps = {
31
+ /** e.g. "RANKED · CUISINE". */
32
+ kicker?: string;
33
+ title: string;
34
+ /** One paragraph: the size of the pool, and what the order means. */
35
+ lede?: string;
36
+ /** e.g. "Updated 6 September 2026". */
37
+ updatedLabel?: string;
38
+ /** e.g. "247 kitchens considered · 4,318 reviews read". */
39
+ metaLabel?: string;
40
+ exits?: TopExit[];
41
+ /** Finished CSS gradient for the ground. */
42
+ gradient?: string;
43
+ /** Watermark behind the text — the category's icon, resolved by the caller. */
44
+ icon?: React.ReactNode;
45
+ linkComponent?: MenuLinkComponent;
46
+ className?: string;
47
+ };
48
+
49
+ export function TopRankingHero({
50
+ kicker,
51
+ title,
52
+ lede,
53
+ updatedLabel,
54
+ metaLabel,
55
+ exits,
56
+ gradient,
57
+ icon,
58
+ linkComponent: LinkComponent = DefaultLink,
59
+ className,
60
+ }: TopRankingHeroProps) {
61
+ return (
62
+ <header
63
+ className={cn(
64
+ "relative overflow-hidden rounded-[18px] px-5 py-6 lg:px-8 lg:py-8",
65
+ !gradient && "bg-muted",
66
+ className
67
+ )}
68
+ >
69
+ {gradient && (
70
+ <span aria-hidden className="absolute inset-0" style={{ backgroundImage: gradient }} />
71
+ )}
72
+
73
+ {icon && (
74
+ <span
75
+ aria-hidden
76
+ className="pointer-events-none absolute -right-6 -top-6 opacity-[0.14] [&>svg]:h-[150px] [&>svg]:w-[150px] lg:[&>svg]:h-[190px] lg:[&>svg]:w-[190px]"
77
+ >
78
+ {icon}
79
+ </span>
80
+ )}
81
+
82
+ <div className="relative z-[1]">
83
+ {kicker && (
84
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-background/70 px-3 py-1 text-[11px] font-extrabold uppercase tracking-[0.09em] backdrop-blur-[2px]">
85
+ <Trophy className="h-3 w-3 shrink-0" aria-hidden />
86
+ {kicker}
87
+ </span>
88
+ )}
89
+
90
+ <h1 className="mt-3 max-w-[22ch] font-serif text-[30px] font-bold leading-[1.08] tracking-[-0.025em] lg:text-[44px]">
91
+ {title}
92
+ </h1>
93
+
94
+ {lede && (
95
+ <p className="mt-3 max-w-[62ch] text-[14.5px] leading-relaxed opacity-85">{lede}</p>
96
+ )}
97
+
98
+ {(updatedLabel || metaLabel) && (
99
+ <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12.5px]">
100
+ {updatedLabel && (
101
+ <span className="inline-flex items-center gap-1.5 font-semibold">
102
+ <CalendarCheck className="h-3.5 w-3.5 shrink-0" aria-hidden />
103
+ {updatedLabel}
104
+ </span>
105
+ )}
106
+ {updatedLabel && metaLabel && (
107
+ <span aria-hidden className="h-1 w-1 rounded-full bg-current opacity-40" />
108
+ )}
109
+ {metaLabel && <span className="opacity-75">{metaLabel}</span>}
110
+ </div>
111
+ )}
112
+
113
+ {/*
114
+ Desktop only, and not for want of room.
115
+
116
+ On a phone the hero already fills the first screen with the kicker,
117
+ a three-line serif headline, the date and the pool size — everything
118
+ that says what this page is. Two full-width buttons under that put a
119
+ call to action above the first entry, so the reader meets an advert
120
+ before they meet the ranking, and the page's whole argument is that it
121
+ is worth reading. The exits are stated again under the list, where a
122
+ reader who has read the argument is actually deciding what to do next.
123
+
124
+ Beside a 44px headline on a wide hero they are a row of two, cost one
125
+ line, and read as orientation rather than as a pitch.
126
+ */}
127
+ {exits && exits.length > 0 && (
128
+ <div className="mt-5 hidden flex-col gap-2 sm:flex-row lg:flex">
129
+ {exits.map((exit, index) => (
130
+ <LinkComponent
131
+ key={exit.href}
132
+ href={exit.href}
133
+ className={cn(
134
+ "inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none",
135
+ index === 0
136
+ ? "bg-background text-foreground hover:bg-background/90"
137
+ : "border border-current/25 hover:bg-background/25"
138
+ )}
139
+ >
140
+ {exit.icon ?? <LayoutList className="h-4 w-4 shrink-0" aria-hidden />}
141
+ {exit.label}
142
+ <ArrowRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
143
+ </LinkComponent>
144
+ ))}
145
+ </div>
146
+ )}
147
+ </div>
148
+ </header>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * TopMethodNote — how the ranking was computed, in one block.
154
+ *
155
+ * Under the hero rather than at the foot: a reader deciding whether to trust a
156
+ * list decides it before reading the list, not after.
157
+ */
158
+ /**
159
+ * The note's surface, exported so a caller can put the same look on a TRIGGER.
160
+ *
161
+ * On `/top` the banner opens the grade explainer, and that explainer owns its
162
+ * own `<button>` (Radix clones it, so it cannot be handed a ready-made
163
+ * element). The caller therefore needs the surface without the element — hence
164
+ * a class string rather than a second component. `cn` at the call site lets the
165
+ * button's own `inline-flex` lose to the `flex` here.
166
+ */
167
+ export const topMethodNoteSurface =
168
+ "flex w-full items-start gap-3 rounded-[14px] bg-muted p-4 text-left";
169
+
170
+ export type TopMethodNoteProps = {
171
+ /** The heading fragment, e.g. "How this is ranked." Rendered bold, inline. */
172
+ heading: string;
173
+ children: React.ReactNode;
174
+ /** Rendered after the prose — a chevron where the note is a disclosure trigger. */
175
+ affordance?: React.ReactNode;
176
+ className?: string;
177
+ };
178
+
179
+ /**
180
+ * The note's CONTENT, with no element of its own.
181
+ *
182
+ * Every tag here is phrasing content — spans, not paragraphs — because this
183
+ * renders inside a `<button>` when the note is a trigger, and a `<p>` inside a
184
+ * button is invalid. Keeping one definition of the content means the static
185
+ * note and the interactive one cannot drift apart.
186
+ */
187
+ export function TopMethodNoteBody({
188
+ heading,
189
+ children,
190
+ affordance,
191
+ }: Omit<TopMethodNoteProps, "className">) {
192
+ return (
193
+ <>
194
+ <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
195
+ <span className="block text-[13px] leading-relaxed text-muted-foreground">
196
+ <b className="font-bold text-foreground">{heading}</b> {children}
197
+ </span>
198
+ {affordance}
199
+ </>
200
+ );
201
+ }
202
+
203
+ export function TopMethodNote({ heading, children, affordance, className }: TopMethodNoteProps) {
204
+ return (
205
+ <aside className={cn(topMethodNoteSurface, className)}>
206
+ <TopMethodNoteBody heading={heading} affordance={affordance}>
207
+ {children}
208
+ </TopMethodNoteBody>
209
+ </aside>
210
+ );
211
+ }
212
+
213
+ /*
214
+ * `TopExitRow` used to live here. It is now `TopCrossLink` in
215
+ * `top-cross-link.tsx`, with a `panel` variant — the same object is needed in
216
+ * both directions (ranking → browse, and browse → ranking), and its old name
217
+ * described only the outbound half.
218
+ */
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TopRankingLegend } from "./top-ranking-legend";
3
+
4
+ const groups = [
5
+ {
6
+ key: "exceptional",
7
+ label: "Exceptional",
8
+ entries: [
9
+ { rank: 1, slug: "a", name: "PASQUALE | brunch & dinner" },
10
+ { rank: 2, slug: "b", name: "Alle Bollicine Vino & Pasta" },
11
+ { rank: 3, slug: "c", name: "Mare Nera Seafood Restaurant" },
12
+ { rank: 4, slug: "d", name: "La 502" },
13
+ ],
14
+ },
15
+ {
16
+ key: "excellent",
17
+ label: "Excellent",
18
+ entries: [
19
+ { rank: 5, slug: "e", name: "Tortelli Pasta Bar" },
20
+ { rank: 6, slug: "f", name: "Il forno - vera pizza napoletana" },
21
+ { rank: 7, slug: "g", name: "Perfetto Cucina Italiana" },
22
+ { rank: 8, slug: "h", name: "CUCINA TUREA BACANIE cu de toate" },
23
+ ],
24
+ },
25
+ {
26
+ key: "very-good",
27
+ label: "Very good",
28
+ entries: [
29
+ { rank: 9, slug: "i", name: "GARLIC - bites&tales" },
30
+ { rank: 10, slug: "j", name: "Pizzeria Dino" },
31
+ { rank: 11, slug: "k", name: "Motor Rock Bistro" },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ const meta: Meta<typeof TopRankingLegend> = {
37
+ title: "Top/TopRankingLegend",
38
+ component: TopRankingLegend,
39
+ tags: ["autodocs"],
40
+ args: { groups, heading: "The ranking", meta: "25 places · updated September 2026" },
41
+ decorators: [
42
+ (Story) => (
43
+ <div className="w-[280px] bg-background p-4">
44
+ <Story />
45
+ </div>
46
+ ),
47
+ ],
48
+ parameters: {
49
+ docs: {
50
+ description: {
51
+ component:
52
+ "The ranking as a place you are inside of, not a table. It was rank / name / grade with a rule under every row: the **grade column** was the loudest thing in it and the least useful for navigating — twenty-five near-identical numbers duplicating a grade shown at full size beside them, eating the width that truncated the names. The **dividers** ran full-bleed to the column edge so nothing ever ended the aside. And there was **no active row at all**, in a component whose whole job is orientation. Now: no grade, no rules, grouped by grade band, and the current entry tracked by an `IntersectionObserver` over a narrow band near the top of the viewport. The **rank numeral is the rail** — a status ring would promise completion state we do not track and would never fill. Below `lg` it is the flat chip row, unchanged.",
53
+ },
54
+ },
55
+ },
56
+ };
57
+
58
+ export default meta;
59
+ type Story = StoryObj<typeof TopRankingLegend>;
60
+
61
+ /** No entry is active without a scrolling article behind it — that is correct here. */
62
+ export const Default: Story = {};
63
+
64
+ /** Long names wrap rather than truncate: the label you navigate by is never cut. */
65
+ export const LongNames: Story = {
66
+ args: { groups: [groups[1]] },
67
+ };
68
+
69
+ /** The phone keeps the flat chip row. */
70
+ export const Mobile: Story = {
71
+ decorators: [
72
+ (Story) => (
73
+ <div className="w-[390px] bg-background p-3">
74
+ <Story />
75
+ </div>
76
+ ),
77
+ ],
78
+ };
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * TopRankingLegend — the ranking as a place you are inside of, not a table.
8
+ *
9
+ * ## What it stopped being
10
+ *
11
+ * It was a two-column table: rank, name, grade, with a rule under every row.
12
+ * Three things were wrong with that, and they compounded.
13
+ *
14
+ * The **grade column** was the loudest thing in it and the least useful thing
15
+ * for navigating — twenty-five near-identical numbers in accent colour,
16
+ * duplicating a grade shown at full size two inches to the right, and eating
17
+ * the width that was truncating the names. The one field you navigate by was
18
+ * the one being cut. It is gone.
19
+ *
20
+ * The **dividers** ran full-bleed to the column's right edge, so every rule was
21
+ * a small arrow pointing at the article and nothing ever visually ended the
22
+ * aside. Space separates now; contrast is spent on state instead.
23
+ *
24
+ * And there was **no active row at all** — twenty-five identical entries in a
25
+ * component whose entire job is orientation. That is what the observer below
26
+ * is for.
27
+ *
28
+ * ## The rank numeral is the rail
29
+ *
30
+ * The reference this is drawn from uses a status ring down the left edge. A
31
+ * ring is wrong here: it promises completion state we do not track and would
32
+ * never fill. The rank numeral is already present, already tabular, and is
33
+ * itself the thing being ranked — a better rail, for free.
34
+ *
35
+ * ## Grouped, because twenty-five is not a list you can hold
36
+ *
37
+ * By grade band, which is the one honest grouping available: the tiers are
38
+ * already printed on the entries, so the aside is not inventing a taxonomy. It
39
+ * also says something true — that the top few are a different class.
40
+ *
41
+ * The column will still exceed the viewport at twenty-five, and that is
42
+ * accepted rather than solved: an internally-scrolling sidebar is a second page,
43
+ * and grouping plus a live active row makes the overflow navigable.
44
+ *
45
+ * ## The phone keeps its chips
46
+ *
47
+ * Below `lg` this is a horizontal scroller of the flat list, unchanged. None of
48
+ * the above applies to a chip row — there is nothing to group, no column to
49
+ * inset, and the active state is a press away rather than a scroll away.
50
+ */
51
+
52
+ export type TopRankingLegendEntry = {
53
+ rank: number;
54
+ slug: string;
55
+ name: string;
56
+ };
57
+
58
+ export type TopRankingLegendGroup = {
59
+ key: string;
60
+ /** Grade band, e.g. "Exceptional". Letterspaced caps — structure, not content. */
61
+ label: string;
62
+ entries: TopRankingLegendEntry[];
63
+ };
64
+
65
+ export type TopRankingLegendProps = {
66
+ groups: TopRankingLegendGroup[];
67
+ /** e.g. "The ranking". */
68
+ heading: string;
69
+ /** The size and freshness of the thing, e.g. "25 places · updated September 2026". */
70
+ meta?: string;
71
+ className?: string;
72
+ };
73
+
74
+ /**
75
+ * Only entries crossing the upper fifth of the viewport count as current.
76
+ *
77
+ * A plain `isIntersecting` marks every entry on screen — three or four at
78
+ * desktop height — and the legend flickers between them. Narrowing the band to
79
+ * a strip near the top means "current" is the entry you are reading rather than
80
+ * the entries you can see.
81
+ */
82
+ const ACTIVE_BAND = "-20% 0px -75% 0px";
83
+
84
+ export function TopRankingLegend({ groups, heading, meta, className }: TopRankingLegendProps) {
85
+ const entries = groups.flatMap((group) => group.entries);
86
+ const [activeSlug, setActiveSlug] = useState<string | null>(null);
87
+
88
+ useEffect(() => {
89
+ if (typeof IntersectionObserver === "undefined") return undefined;
90
+
91
+ const nodes = entries
92
+ .map((entry) => document.getElementById(entry.slug))
93
+ .filter((node): node is HTMLElement => Boolean(node));
94
+ if (nodes.length === 0) return undefined;
95
+
96
+ const observer = new IntersectionObserver(
97
+ (observed) => {
98
+ const inBand = observed.filter((record) => record.isIntersecting);
99
+ // Nothing in the band means the reader is BETWEEN entries — mid-way
100
+ // through a long one. Holding the last value is right; clearing it
101
+ // would blink the active row off on every gap.
102
+ if (inBand.length === 0) return;
103
+ const topmost = inBand.reduce((best, record) =>
104
+ record.boundingClientRect.top < best.boundingClientRect.top ? record : best
105
+ );
106
+ setActiveSlug(topmost.target.id);
107
+ },
108
+ { rootMargin: ACTIVE_BAND }
109
+ );
110
+
111
+ nodes.forEach((node) => observer.observe(node));
112
+ return () => observer.disconnect();
113
+ // Re-observes when the ranking itself changes, not on every render.
114
+ }, [entries.map((entry) => entry.slug).join("|")]);
115
+
116
+ return (
117
+ /*
118
+ * The height cap is a safety net, not the navigation model.
119
+ *
120
+ * `position: sticky` on an element TALLER than the viewport leaves its
121
+ * bottom permanently unreachable — at twenty-five rows on a 900px laptop
122
+ * you could never see ranks 20–25, however you scrolled. That is broken
123
+ * rather than merely tight, so the column scrolls internally once it
124
+ * exceeds the space available.
125
+ *
126
+ * It only ever engages on a viewport too short to fit the list: `max-height`
127
+ * with `overflow-y-auto` is inert when the content already fits, so nothing
128
+ * changes on a tall screen. `overscroll-contain` stops a flick inside the
129
+ * column from carrying on into the article behind it.
130
+ */
131
+ <nav
132
+ aria-label={heading}
133
+ className={cn(
134
+ /*
135
+ * Sticky at BOTH widths now, with a different offset and a different
136
+ * shape at each.
137
+ *
138
+ * On desktop it is the grouped column, pinned beside the article. On a
139
+ * phone it is the flat chip rail, pinned for the same reason the column
140
+ * is: this page is twenty-five long entries, so a reader at rank 14 is
141
+ * several screens from any means of jumping, and the rail scrolled away
142
+ * inside the first entry.
143
+ *
144
+ * The phone offset comes from `--legend-sticky-top`, which the CALLER
145
+ * sets, because it is that app's chrome — the consumer app pins under a
146
+ * fixed header at `calc(var(--header-offset) + env(safe-area-inset-top))`,
147
+ * exactly as its events day rail does. Reading the app's own
148
+ * `--header-offset` here would resolve to nothing in Storybook and in
149
+ * the other three consumers, and an invalid `calc` silently kills
150
+ * `top`, which turns the rail from mispositioned into not-sticky.
151
+ */
152
+ "sticky top-[var(--legend-sticky-top,0px)] z-20 lg:top-4 lg:z-auto",
153
+ "lg:max-h-[calc(100vh-2rem)] lg:self-start lg:overflow-y-auto lg:overscroll-contain",
154
+ "lg:[scrollbar-width:thin]",
155
+ className
156
+ )}
157
+ >
158
+ {/* Header block. The reference opens with the size of the thing before a
159
+ single row — ours is the count and the date, which were previously
160
+ buried in a footnote under twenty-five rows nobody scrolls past. */}
161
+ <div className="hidden lg:block">
162
+ <h2 className="text-[15px] font-bold tracking-[-0.01em]">{heading}</h2>
163
+ {meta && <p className="mt-1 text-[11.5px] text-muted-foreground">{meta}</p>}
164
+ </div>
165
+
166
+ {/* Phone: the flat chip rail.
167
+
168
+ The negative margin bleeds the band to both edges, so once the `<nav>`
169
+ is pinned it covers the article passing underneath — untinted, the
170
+ entries would scroll straight through the chips.
171
+
172
+ OPAQUE, not the events day rail's translucent-plus-blur. That rail
173
+ passes over body text, where a blur reads as depth; this one passes
174
+ over 25 photographs, and at 85% a cover's dark edge showed straight
175
+ through the chips — the strip stopped being a surface and started
176
+ looking like a rendering fault. `pb-3` is the rail's own breathing
177
+ room: at `py-0` the chips sat hard against the section heading below
178
+ and read as part of it. */}
179
+ <ul className="scrollbar-hide -mx-[var(--page-gutter)] flex gap-2 overflow-x-auto bg-background px-[var(--page-gutter)] pb-3 pt-1 lg:hidden">
180
+ {entries.map((entry) => (
181
+ <li key={entry.slug} className="shrink-0">
182
+ <a
183
+ href={`#${entry.slug}`}
184
+ className="inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-full border border-border bg-card px-3.5 text-[13px] transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
185
+ >
186
+ <span className="font-bold tabular-nums text-muted-foreground">{entry.rank}</span>
187
+ {entry.name}
188
+ </a>
189
+ </li>
190
+ ))}
191
+ </ul>
192
+
193
+ {/* Desktop: grouped column. `-mx-3` so the active pill reaches the column
194
+ edge while the text stays inset — the separation lives inside the
195
+ column, which is most of why the reference breathes. */}
196
+ <div className="hidden lg:mt-5 lg:-mx-3 lg:block">
197
+ {groups.map((group) => (
198
+ <div key={group.key} className="mb-4 last:mb-0">
199
+ <p className="px-3 pb-1.5 text-[10.5px] font-bold uppercase tracking-[0.11em] text-muted-foreground">
200
+ {group.label}
201
+ </p>
202
+ <ul>
203
+ {group.entries.map((entry) => {
204
+ const isActive = entry.slug === activeSlug;
205
+ return (
206
+ <li key={entry.slug}>
207
+ <a
208
+ href={`#${entry.slug}`}
209
+ aria-current={isActive ? "location" : undefined}
210
+ className={cn(
211
+ "flex gap-2.5 rounded-lg px-3 py-2 text-[13px] leading-snug transition-colors motion-reduce:transition-none",
212
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
213
+ isActive
214
+ ? "bg-accent-subtle font-semibold text-foreground"
215
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
216
+ )}
217
+ >
218
+ <span
219
+ className={cn(
220
+ "w-5 shrink-0 text-right font-bold tabular-nums",
221
+ isActive ? "text-accent" : "text-muted-foreground/70"
222
+ )}
223
+ >
224
+ {entry.rank}
225
+ </span>
226
+ {/* Wraps rather than truncates: the label you navigate by
227
+ is the one thing that must never be cut. */}
228
+ <span className="min-w-0 flex-1">{entry.name}</span>
229
+ </a>
230
+ </li>
231
+ );
232
+ })}
233
+ </ul>
234
+ </div>
235
+ ))}
236
+ </div>
237
+ </nav>
238
+ );
239
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The editorial ranking's view model — `/top/[category]`.
3
+ *
4
+ * Separate from `menu-types.ts` on purpose. The menu surfaces describe what a
5
+ * city HAS; these describe an argument the product is making about it, and the
6
+ * shapes reflect that: an entry carries a cited quote and a dated price because
7
+ * a rank is a claim and the page has to show why.
8
+ *
9
+ * Same contract as everywhere else: no app schemas, no hooks, no i18n. Strings
10
+ * arrive through labels, destinations through `href`, numbers through injected
11
+ * formatters.
12
+ */
13
+
14
+ import type { ReactNode } from 'react'
15
+
16
+ import type { MenuItemPreview } from './menu-types'
17
+
18
+ /** One entry in the ranking. */
19
+ export type TopRankEntry = {
20
+ rank: number
21
+ slug: string
22
+ name: string
23
+ /** The venue page. */
24
+ href: string
25
+ /** Neighbourhood, e.g. "Centru". */
26
+ district?: string
27
+ /** Pre-resolved venue photograph. */
28
+ coverUrl?: string
29
+ /** The app's grade, already formatted — "9.4". Absent where none exists. */
30
+ gradeLabel?: string
31
+ /** The band that grade falls in, e.g. "Exceptional". Composed by the caller. */
32
+ tierLabel?: string
33
+ /** Third-party star rating, shown only where there is no grade. */
34
+ rating?: number
35
+ /**
36
+ * The editorial line — why this one is here, in the product's own voice.
37
+ *
38
+ * Distinct from `evidence`, which is a reviewer's words. Conflating them was
39
+ * the old page's problem: a quote presented as editorial reads as invented,
40
+ * and editorial presented as a quote reads as dishonest.
41
+ */
42
+ editorial?: string
43
+ /**
44
+ * The dishes this venue is named for, as PRODUCTION MENU ROWS.
45
+ *
46
+ * `MenuItemPreview`, so the entry renders the same `<MenuItem>` a venue's own
47
+ * menu and every hub surface renders. The first pass here hand-rolled a row —
48
+ * a bold name, a muted description, a price — which drifted from the real one
49
+ * immediately and meant a dish looked like a different object on this page
50
+ * than on every other.
51
+ *
52
+ * More than one, because a rank is a claim about a kitchen and one dish
53
+ * rarely carries it: the live page shows the group and two or three of its
54
+ * most-named plates.
55
+ */
56
+ menuItems?: MenuItemPreview[]
57
+ /** The group those items sit in, e.g. "Pizza". From the venue's own menu. */
58
+ menuGroupLabel?: string
59
+ /** A reviewer's own words, and how many said it. */
60
+ evidence?: TopEvidence
61
+ }
62
+
63
+ export type TopEvidence = {
64
+ /**
65
+ * A reviewer's own words. OPTIONAL: a venue can carry a mention count with no
66
+ * quotable snippet behind it, and the count is evidence in its own right —
67
+ * dropping the whole block for want of a quote loses the number that
68
+ * justifies the rank.
69
+ */
70
+ quote?: string
71
+ /**
72
+ * How many reviews back it — the figure alone, e.g. "37". Rendered as its own
73
+ * element beside `sourceLabel` so the number reads as a figure rather than as
74
+ * the first word of a sentence.
75
+ */
76
+ count?: string
77
+ /** What the count is evidence OF, e.g. "reviews praise the shawarma here". */
78
+ sourceLabel: string
79
+ /**
80
+ * The rest of the cited reviews, behind a disclosure.
81
+ *
82
+ * A `<details>`, not a drawer, and that is the whole point: its contents stay
83
+ * in the HTML, so the evidence is crawlable and readable with no JavaScript.
84
+ * A page whose entire argument is "here is why" cannot ship most of the why
85
+ * in a modal.
86
+ */
87
+ extraQuotes?: string[]
88
+ /** Summary text for the disclosure, e.g. "2 more reviews". Pluralised by the caller. */
89
+ extraLabel?: string
90
+ }
91
+
92
+ /** One row of the side-by-side comparison. */
93
+ export type TopComparisonRow = {
94
+ rank: number
95
+ name: string
96
+ href: string
97
+ gradeLabel?: string
98
+ signature?: string
99
+ priceLabel?: string
100
+ /** Kitchen serving past midnight. */
101
+ late?: boolean
102
+ terrace?: boolean
103
+ }
104
+
105
+ /** A way out of the article and into the browsing tool. */
106
+ export type TopExit = {
107
+ label: string
108
+ href: string
109
+ icon?: ReactNode
110
+ }