@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,61 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { MenuLinkComponent } from './menu-types.js';
3
+ import { TopExit } from './top-types.js';
4
+ import 'react';
5
+ import './place-types.js';
6
+
7
+ type TopRankingHeroProps = {
8
+ /** e.g. "RANKED · CUISINE". */
9
+ kicker?: string;
10
+ title: string;
11
+ /** One paragraph: the size of the pool, and what the order means. */
12
+ lede?: string;
13
+ /** e.g. "Updated 6 September 2026". */
14
+ updatedLabel?: string;
15
+ /** e.g. "247 kitchens considered · 4,318 reviews read". */
16
+ metaLabel?: string;
17
+ exits?: TopExit[];
18
+ /** Finished CSS gradient for the ground. */
19
+ gradient?: string;
20
+ /** Watermark behind the text — the category's icon, resolved by the caller. */
21
+ icon?: React.ReactNode;
22
+ linkComponent?: MenuLinkComponent;
23
+ className?: string;
24
+ };
25
+ declare function TopRankingHero({ kicker, title, lede, updatedLabel, metaLabel, exits, gradient, icon, linkComponent: LinkComponent, className, }: TopRankingHeroProps): react_jsx_runtime.JSX.Element;
26
+ /**
27
+ * TopMethodNote — how the ranking was computed, in one block.
28
+ *
29
+ * Under the hero rather than at the foot: a reader deciding whether to trust a
30
+ * list decides it before reading the list, not after.
31
+ */
32
+ /**
33
+ * The note's surface, exported so a caller can put the same look on a TRIGGER.
34
+ *
35
+ * On `/top` the banner opens the grade explainer, and that explainer owns its
36
+ * own `<button>` (Radix clones it, so it cannot be handed a ready-made
37
+ * element). The caller therefore needs the surface without the element — hence
38
+ * a class string rather than a second component. `cn` at the call site lets the
39
+ * button's own `inline-flex` lose to the `flex` here.
40
+ */
41
+ declare const topMethodNoteSurface = "flex w-full items-start gap-3 rounded-[14px] bg-muted p-4 text-left";
42
+ type TopMethodNoteProps = {
43
+ /** The heading fragment, e.g. "How this is ranked." Rendered bold, inline. */
44
+ heading: string;
45
+ children: React.ReactNode;
46
+ /** Rendered after the prose — a chevron where the note is a disclosure trigger. */
47
+ affordance?: React.ReactNode;
48
+ className?: string;
49
+ };
50
+ /**
51
+ * The note's CONTENT, with no element of its own.
52
+ *
53
+ * Every tag here is phrasing content — spans, not paragraphs — because this
54
+ * renders inside a `<button>` when the note is a trigger, and a `<p>` inside a
55
+ * button is invalid. Keeping one definition of the content means the static
56
+ * note and the interactive one cannot drift apart.
57
+ */
58
+ declare function TopMethodNoteBody({ heading, children, affordance, }: Omit<TopMethodNoteProps, "className">): react_jsx_runtime.JSX.Element;
59
+ declare function TopMethodNote({ heading, children, affordance, className }: TopMethodNoteProps): react_jsx_runtime.JSX.Element;
60
+
61
+ export { TopMethodNote, TopMethodNoteBody, type TopMethodNoteProps, TopRankingHero, type TopRankingHeroProps, topMethodNoteSurface };
@@ -0,0 +1,99 @@
1
+ import { cn } from '../chunk-FEK5XGZW.js';
2
+ import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
3
+ import { Trophy, CalendarCheck, LayoutList, ArrowRight, Info } from 'lucide-react';
4
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
+
6
+ function DefaultLink(_a) {
7
+ var _b = _a, { href } = _b, props = __objRest(_b, ["href"]);
8
+ return /* @__PURE__ */ jsx("a", __spreadValues({ href }, props));
9
+ }
10
+ function TopRankingHero({
11
+ kicker,
12
+ title,
13
+ lede,
14
+ updatedLabel,
15
+ metaLabel,
16
+ exits,
17
+ gradient,
18
+ icon,
19
+ linkComponent: LinkComponent = DefaultLink,
20
+ className
21
+ }) {
22
+ return /* @__PURE__ */ jsxs(
23
+ "header",
24
+ {
25
+ className: cn(
26
+ "relative overflow-hidden rounded-[18px] px-5 py-6 lg:px-8 lg:py-8",
27
+ !gradient && "bg-muted",
28
+ className
29
+ ),
30
+ children: [
31
+ gradient && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "absolute inset-0", style: { backgroundImage: gradient } }),
32
+ icon && /* @__PURE__ */ jsx(
33
+ "span",
34
+ {
35
+ "aria-hidden": true,
36
+ 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]",
37
+ children: icon
38
+ }
39
+ ),
40
+ /* @__PURE__ */ jsxs("div", { className: "relative z-[1]", children: [
41
+ kicker && /* @__PURE__ */ jsxs("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]", children: [
42
+ /* @__PURE__ */ jsx(Trophy, { className: "h-3 w-3 shrink-0", "aria-hidden": true }),
43
+ kicker
44
+ ] }),
45
+ /* @__PURE__ */ jsx("h1", { className: "mt-3 max-w-[22ch] font-serif text-[30px] font-bold leading-[1.08] tracking-[-0.025em] lg:text-[44px]", children: title }),
46
+ lede && /* @__PURE__ */ jsx("p", { className: "mt-3 max-w-[62ch] text-[14.5px] leading-relaxed opacity-85", children: lede }),
47
+ (updatedLabel || metaLabel) && /* @__PURE__ */ jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12.5px]", children: [
48
+ updatedLabel && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5 font-semibold", children: [
49
+ /* @__PURE__ */ jsx(CalendarCheck, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": true }),
50
+ updatedLabel
51
+ ] }),
52
+ updatedLabel && metaLabel && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "h-1 w-1 rounded-full bg-current opacity-40" }),
53
+ metaLabel && /* @__PURE__ */ jsx("span", { className: "opacity-75", children: metaLabel })
54
+ ] }),
55
+ exits && exits.length > 0 && /* @__PURE__ */ jsx("div", { className: "mt-5 hidden flex-col gap-2 sm:flex-row lg:flex", children: exits.map((exit, index) => {
56
+ var _a;
57
+ return /* @__PURE__ */ jsxs(
58
+ LinkComponent,
59
+ {
60
+ href: exit.href,
61
+ className: cn(
62
+ "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",
63
+ index === 0 ? "bg-background text-foreground hover:bg-background/90" : "border border-current/25 hover:bg-background/25"
64
+ ),
65
+ children: [
66
+ (_a = exit.icon) != null ? _a : /* @__PURE__ */ jsx(LayoutList, { className: "h-4 w-4 shrink-0", "aria-hidden": true }),
67
+ exit.label,
68
+ /* @__PURE__ */ jsx(ArrowRight, { className: "h-3.5 w-3.5 shrink-0", "aria-hidden": true })
69
+ ]
70
+ },
71
+ exit.href
72
+ );
73
+ }) })
74
+ ] })
75
+ ]
76
+ }
77
+ );
78
+ }
79
+ var topMethodNoteSurface = "flex w-full items-start gap-3 rounded-[14px] bg-muted p-4 text-left";
80
+ function TopMethodNoteBody({
81
+ heading,
82
+ children,
83
+ affordance
84
+ }) {
85
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
86
+ /* @__PURE__ */ jsx(Info, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground", "aria-hidden": true }),
87
+ /* @__PURE__ */ jsxs("span", { className: "block text-[13px] leading-relaxed text-muted-foreground", children: [
88
+ /* @__PURE__ */ jsx("b", { className: "font-bold text-foreground", children: heading }),
89
+ " ",
90
+ children
91
+ ] }),
92
+ affordance
93
+ ] });
94
+ }
95
+ function TopMethodNote({ heading, children, affordance, className }) {
96
+ return /* @__PURE__ */ jsx("aside", { className: cn(topMethodNoteSurface, className), children: /* @__PURE__ */ jsx(TopMethodNoteBody, { heading, affordance, children }) });
97
+ }
98
+
99
+ export { TopMethodNote, TopMethodNoteBody, TopRankingHero, topMethodNoteSurface };
@@ -0,0 +1,69 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * TopRankingLegend — the ranking as a place you are inside of, not a table.
5
+ *
6
+ * ## What it stopped being
7
+ *
8
+ * It was a two-column table: rank, name, grade, with a rule under every row.
9
+ * Three things were wrong with that, and they compounded.
10
+ *
11
+ * The **grade column** was the loudest thing in it and the least useful thing
12
+ * for navigating — twenty-five near-identical numbers in accent colour,
13
+ * duplicating a grade shown at full size two inches to the right, and eating
14
+ * the width that was truncating the names. The one field you navigate by was
15
+ * the one being cut. It is gone.
16
+ *
17
+ * The **dividers** ran full-bleed to the column's right edge, so every rule was
18
+ * a small arrow pointing at the article and nothing ever visually ended the
19
+ * aside. Space separates now; contrast is spent on state instead.
20
+ *
21
+ * And there was **no active row at all** — twenty-five identical entries in a
22
+ * component whose entire job is orientation. That is what the observer below
23
+ * is for.
24
+ *
25
+ * ## The rank numeral is the rail
26
+ *
27
+ * The reference this is drawn from uses a status ring down the left edge. A
28
+ * ring is wrong here: it promises completion state we do not track and would
29
+ * never fill. The rank numeral is already present, already tabular, and is
30
+ * itself the thing being ranked — a better rail, for free.
31
+ *
32
+ * ## Grouped, because twenty-five is not a list you can hold
33
+ *
34
+ * By grade band, which is the one honest grouping available: the tiers are
35
+ * already printed on the entries, so the aside is not inventing a taxonomy. It
36
+ * also says something true — that the top few are a different class.
37
+ *
38
+ * The column will still exceed the viewport at twenty-five, and that is
39
+ * accepted rather than solved: an internally-scrolling sidebar is a second page,
40
+ * and grouping plus a live active row makes the overflow navigable.
41
+ *
42
+ * ## The phone keeps its chips
43
+ *
44
+ * Below `lg` this is a horizontal scroller of the flat list, unchanged. None of
45
+ * the above applies to a chip row — there is nothing to group, no column to
46
+ * inset, and the active state is a press away rather than a scroll away.
47
+ */
48
+ type TopRankingLegendEntry = {
49
+ rank: number;
50
+ slug: string;
51
+ name: string;
52
+ };
53
+ type TopRankingLegendGroup = {
54
+ key: string;
55
+ /** Grade band, e.g. "Exceptional". Letterspaced caps — structure, not content. */
56
+ label: string;
57
+ entries: TopRankingLegendEntry[];
58
+ };
59
+ type TopRankingLegendProps = {
60
+ groups: TopRankingLegendGroup[];
61
+ /** e.g. "The ranking". */
62
+ heading: string;
63
+ /** The size and freshness of the thing, e.g. "25 places · updated September 2026". */
64
+ meta?: string;
65
+ className?: string;
66
+ };
67
+ declare function TopRankingLegend({ groups, heading, meta, className }: TopRankingLegendProps): react_jsx_runtime.JSX.Element;
68
+
69
+ export { TopRankingLegend, type TopRankingLegendEntry, type TopRankingLegendGroup, type TopRankingLegendProps };
@@ -0,0 +1,124 @@
1
+ import { cn } from '../chunk-FEK5XGZW.js';
2
+ import '../chunk-3GWWZKX7.js';
3
+ import { useState, useEffect } from 'react';
4
+ import { jsxs, jsx } from 'react/jsx-runtime';
5
+
6
+ var ACTIVE_BAND = "-20% 0px -75% 0px";
7
+ function TopRankingLegend({ groups, heading, meta, className }) {
8
+ const entries = groups.flatMap((group) => group.entries);
9
+ const [activeSlug, setActiveSlug] = useState(null);
10
+ useEffect(() => {
11
+ if (typeof IntersectionObserver === "undefined") return void 0;
12
+ const nodes = entries.map((entry) => document.getElementById(entry.slug)).filter((node) => Boolean(node));
13
+ if (nodes.length === 0) return void 0;
14
+ const observer = new IntersectionObserver(
15
+ (observed) => {
16
+ const inBand = observed.filter((record) => record.isIntersecting);
17
+ if (inBand.length === 0) return;
18
+ const topmost = inBand.reduce(
19
+ (best, record) => record.boundingClientRect.top < best.boundingClientRect.top ? record : best
20
+ );
21
+ setActiveSlug(topmost.target.id);
22
+ },
23
+ { rootMargin: ACTIVE_BAND }
24
+ );
25
+ nodes.forEach((node) => observer.observe(node));
26
+ return () => observer.disconnect();
27
+ }, [entries.map((entry) => entry.slug).join("|")]);
28
+ return (
29
+ /*
30
+ * The height cap is a safety net, not the navigation model.
31
+ *
32
+ * `position: sticky` on an element TALLER than the viewport leaves its
33
+ * bottom permanently unreachable — at twenty-five rows on a 900px laptop
34
+ * you could never see ranks 20–25, however you scrolled. That is broken
35
+ * rather than merely tight, so the column scrolls internally once it
36
+ * exceeds the space available.
37
+ *
38
+ * It only ever engages on a viewport too short to fit the list: `max-height`
39
+ * with `overflow-y-auto` is inert when the content already fits, so nothing
40
+ * changes on a tall screen. `overscroll-contain` stops a flick inside the
41
+ * column from carrying on into the article behind it.
42
+ */
43
+ /* @__PURE__ */ jsxs(
44
+ "nav",
45
+ {
46
+ "aria-label": heading,
47
+ className: cn(
48
+ /*
49
+ * Sticky at BOTH widths now, with a different offset and a different
50
+ * shape at each.
51
+ *
52
+ * On desktop it is the grouped column, pinned beside the article. On a
53
+ * phone it is the flat chip rail, pinned for the same reason the column
54
+ * is: this page is twenty-five long entries, so a reader at rank 14 is
55
+ * several screens from any means of jumping, and the rail scrolled away
56
+ * inside the first entry.
57
+ *
58
+ * The phone offset comes from `--legend-sticky-top`, which the CALLER
59
+ * sets, because it is that app's chrome — the consumer app pins under a
60
+ * fixed header at `calc(var(--header-offset) + env(safe-area-inset-top))`,
61
+ * exactly as its events day rail does. Reading the app's own
62
+ * `--header-offset` here would resolve to nothing in Storybook and in
63
+ * the other three consumers, and an invalid `calc` silently kills
64
+ * `top`, which turns the rail from mispositioned into not-sticky.
65
+ */
66
+ "sticky top-[var(--legend-sticky-top,0px)] z-20 lg:top-4 lg:z-auto",
67
+ "lg:max-h-[calc(100vh-2rem)] lg:self-start lg:overflow-y-auto lg:overscroll-contain",
68
+ "lg:[scrollbar-width:thin]",
69
+ className
70
+ ),
71
+ children: [
72
+ /* @__PURE__ */ jsxs("div", { className: "hidden lg:block", children: [
73
+ /* @__PURE__ */ jsx("h2", { className: "text-[15px] font-bold tracking-[-0.01em]", children: heading }),
74
+ meta && /* @__PURE__ */ jsx("p", { className: "mt-1 text-[11.5px] text-muted-foreground", children: meta })
75
+ ] }),
76
+ /* @__PURE__ */ jsx("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", children: entries.map((entry) => /* @__PURE__ */ jsx("li", { className: "shrink-0", children: /* @__PURE__ */ jsxs(
77
+ "a",
78
+ {
79
+ href: `#${entry.slug}`,
80
+ 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",
81
+ children: [
82
+ /* @__PURE__ */ jsx("span", { className: "font-bold tabular-nums text-muted-foreground", children: entry.rank }),
83
+ entry.name
84
+ ]
85
+ }
86
+ ) }, entry.slug)) }),
87
+ /* @__PURE__ */ jsx("div", { className: "hidden lg:mt-5 lg:-mx-3 lg:block", children: groups.map((group) => /* @__PURE__ */ jsxs("div", { className: "mb-4 last:mb-0", children: [
88
+ /* @__PURE__ */ jsx("p", { className: "px-3 pb-1.5 text-[10.5px] font-bold uppercase tracking-[0.11em] text-muted-foreground", children: group.label }),
89
+ /* @__PURE__ */ jsx("ul", { children: group.entries.map((entry) => {
90
+ const isActive = entry.slug === activeSlug;
91
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
92
+ "a",
93
+ {
94
+ href: `#${entry.slug}`,
95
+ "aria-current": isActive ? "location" : void 0,
96
+ className: cn(
97
+ "flex gap-2.5 rounded-lg px-3 py-2 text-[13px] leading-snug transition-colors motion-reduce:transition-none",
98
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
99
+ isActive ? "bg-accent-subtle font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
100
+ ),
101
+ children: [
102
+ /* @__PURE__ */ jsx(
103
+ "span",
104
+ {
105
+ className: cn(
106
+ "w-5 shrink-0 text-right font-bold tabular-nums",
107
+ isActive ? "text-accent" : "text-muted-foreground/70"
108
+ ),
109
+ children: entry.rank
110
+ }
111
+ ),
112
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1", children: entry.name })
113
+ ]
114
+ }
115
+ ) }, entry.slug);
116
+ }) })
117
+ ] }, group.key)) })
118
+ ]
119
+ }
120
+ )
121
+ );
122
+ }
123
+
124
+ export { TopRankingLegend };
@@ -0,0 +1,109 @@
1
+ import { ReactNode } from 'react';
2
+ import { MenuItemPreview } from './menu-types.js';
3
+ import './place-types.js';
4
+
5
+ /**
6
+ * The editorial ranking's view model — `/top/[category]`.
7
+ *
8
+ * Separate from `menu-types.ts` on purpose. The menu surfaces describe what a
9
+ * city HAS; these describe an argument the product is making about it, and the
10
+ * shapes reflect that: an entry carries a cited quote and a dated price because
11
+ * a rank is a claim and the page has to show why.
12
+ *
13
+ * Same contract as everywhere else: no app schemas, no hooks, no i18n. Strings
14
+ * arrive through labels, destinations through `href`, numbers through injected
15
+ * formatters.
16
+ */
17
+
18
+ /** One entry in the ranking. */
19
+ 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
+ type TopEvidence = {
63
+ /**
64
+ * A reviewer's own words. OPTIONAL: a venue can carry a mention count with no
65
+ * quotable snippet behind it, and the count is evidence in its own right —
66
+ * dropping the whole block for want of a quote loses the number that
67
+ * justifies the rank.
68
+ */
69
+ quote?: string;
70
+ /**
71
+ * How many reviews back it — the figure alone, e.g. "37". Rendered as its own
72
+ * element beside `sourceLabel` so the number reads as a figure rather than as
73
+ * the first word of a sentence.
74
+ */
75
+ count?: string;
76
+ /** What the count is evidence OF, e.g. "reviews praise the shawarma here". */
77
+ sourceLabel: string;
78
+ /**
79
+ * The rest of the cited reviews, behind a disclosure.
80
+ *
81
+ * A `<details>`, not a drawer, and that is the whole point: its contents stay
82
+ * in the HTML, so the evidence is crawlable and readable with no JavaScript.
83
+ * A page whose entire argument is "here is why" cannot ship most of the why
84
+ * in a modal.
85
+ */
86
+ extraQuotes?: string[];
87
+ /** Summary text for the disclosure, e.g. "2 more reviews". Pluralised by the caller. */
88
+ extraLabel?: string;
89
+ };
90
+ /** One row of the side-by-side comparison. */
91
+ type TopComparisonRow = {
92
+ rank: number;
93
+ name: string;
94
+ href: string;
95
+ gradeLabel?: string;
96
+ signature?: string;
97
+ priceLabel?: string;
98
+ /** Kitchen serving past midnight. */
99
+ late?: boolean;
100
+ terrace?: boolean;
101
+ };
102
+ /** A way out of the article and into the browsing tool. */
103
+ type TopExit = {
104
+ label: string;
105
+ href: string;
106
+ icon?: ReactNode;
107
+ };
108
+
109
+ export type { TopComparisonRow, TopEvidence, TopExit, TopRankEntry };
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,38 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { PlaceGradeDimensions } from './place-types.js';
3
+
4
+ /**
5
+ * VenueGradeDimensions — the per-dimension review scores as a row of bars.
6
+ *
7
+ * ## Why this renders what it is given rather than a fixed row
8
+ *
9
+ * A dimension the reviews never mention is ABSENT from the payload, not zero,
10
+ * and exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
11
+ * place — a coffee bar has no food score and a restaurant has no coffee one.
12
+ * So a component drawing seven fixed columns would invent readings for a third
13
+ * of the catalogue. It iterates `order`, skips what is missing, and lays out
14
+ * however many survive.
15
+ *
16
+ * Scores arrive 0–100 and display on the 0–10 scale the rest of the product
17
+ * grades on, so the bar width is the raw value and the numeral is that value
18
+ * over ten. `formatScore` is injected because a decimal separator is
19
+ * locale-dependent.
20
+ *
21
+ * Server-safe.
22
+ */
23
+ type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
24
+ /** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
25
+ declare const DIMENSION_ORDER: VenueGradeDimensionKey[];
26
+ type VenueGradeDimensionsProps = {
27
+ dimensions: PlaceGradeDimensions;
28
+ /** Dimension key → translated label. Keys with no label fall back to the key. */
29
+ labels: Partial<Record<VenueGradeDimensionKey, string>>;
30
+ /** Override the canonical order, or narrow which dimensions show at all. */
31
+ order?: VenueGradeDimensionKey[];
32
+ /** 0–100 → display numeral, e.g. `(v) => (v / 10).toFixed(1)`. */
33
+ formatScore: (value: number) => string;
34
+ className?: string;
35
+ };
36
+ declare function VenueGradeDimensions({ dimensions, labels, order, formatScore, className, }: VenueGradeDimensionsProps): react_jsx_runtime.JSX.Element | null;
37
+
38
+ export { DIMENSION_ORDER, type VenueGradeDimensionKey, VenueGradeDimensions, type VenueGradeDimensionsProps };
@@ -0,0 +1,3 @@
1
+ export { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-AKQ57NXY.js';
2
+ import '../chunk-FEK5XGZW.js';
3
+ import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,32 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { CitedText } from './place-types.js';
3
+
4
+ /**
5
+ * VenueQuotes — the digest's standout lines, as pull-quotes.
6
+ *
7
+ * These are `reviews_digest.highlights`: one to three short positives already
8
+ * resolved into the request locale. They are the only place on a hub where a
9
+ * reviewer speaks in their own register, so they are set in serif italic
10
+ * against the sans everything else uses.
11
+ *
12
+ * `onCite` turns a quote into a button that opens the reviews behind it —
13
+ * `review_ids` is what makes the claim checkable, and a quote nobody can trace
14
+ * is just copy. Without the handler the quote renders as inert prose, which is
15
+ * correct on surfaces that have nowhere to open.
16
+ *
17
+ * No directive: inert quotes render on the server; passing `onCite` needs a
18
+ * client caller.
19
+ */
20
+ type VenueQuotesProps = {
21
+ quotes: CitedText[];
22
+ /** Opens the reviews behind one quote. Omit to render inert prose. */
23
+ onCite?: (quote: CitedText) => void;
24
+ /** Accessible name for the cite action, e.g. `(n) => "Read 5 reviews"`. */
25
+ citeLabel?: (reviewCount: number) => string;
26
+ /** Cap on how many render. The digest ships at most three. */
27
+ limit?: number;
28
+ className?: string;
29
+ };
30
+ declare function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: VenueQuotesProps): react_jsx_runtime.JSX.Element | null;
31
+
32
+ export { VenueQuotes, type VenueQuotesProps };
@@ -0,0 +1,3 @@
1
+ export { VenueQuotes } from '../chunk-62ES6N32.js';
2
+ import '../chunk-FEK5XGZW.js';
3
+ import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,41 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { MenuLinkComponent, MenuLinkProps } from './menu-types.js';
3
+ import { PlacePreview } from './place-types.js';
4
+ import 'react';
5
+
6
+ /**
7
+ * VenueWall — the catalogue's scale, stated as a row of the venues in it.
8
+ *
9
+ * A closing beat rather than a browse surface: the avatars are named and
10
+ * linked, but the module's job is to make "we read every menu in the city"
11
+ * land as something specific. It shows a handful and counts the rest, because
12
+ * a wall long enough to be exhaustive stops reading as places.
13
+ *
14
+ * Server-safe.
15
+ */
16
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
17
+ type VenueWallLinkProps = MenuLinkProps;
18
+ type VenueWallProps = {
19
+ places: PlacePreview[];
20
+ /** Destination per place, keyed by slug. Without one an avatar is inert. */
21
+ hrefs?: Record<string, string>;
22
+ kicker?: string;
23
+ kickerIcon?: React.ReactNode;
24
+ headline?: string;
25
+ body?: string;
26
+ /** Composed overflow chip, e.g. "+393 more". */
27
+ overflowLabel?: string;
28
+ /**
29
+ * Tiles for the mosaic ground — the same licensed stills the hero uses.
30
+ * Without them the wall falls back to the flat diagonal texture, which is
31
+ * the degraded state rather than the design.
32
+ */
33
+ tileUrls?: string[];
34
+ /** The action — pass a rendered link or button. */
35
+ action?: React.ReactNode;
36
+ className?: string;
37
+ linkComponent?: MenuLinkComponent;
38
+ };
39
+ declare function VenueWall({ places, hrefs, kicker, kickerIcon, headline, body, overflowLabel, tileUrls, action, linkComponent: LinkComponent, className, }: VenueWallProps): react_jsx_runtime.JSX.Element;
40
+
41
+ export { VenueWall, type VenueWallLinkProps, type VenueWallProps };