@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,84 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TopCrossLink } from "./top-cross-link";
3
+ import { mockGradients } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof TopCrossLink> = {
6
+ title: "Top/TopCrossLink",
7
+ component: TopCrossLink,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The link between the two answers to “where should I eat”. `row` is the way out of the ranking, full width under the list; `panel` is the way in, for the narrow column of a browse page. One component in both directions so the pair cannot drift — and never a sticky bar, because a sticky CTA on an article is an advert.",
14
+ },
15
+ },
16
+ },
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof TopCrossLink>;
21
+
22
+ /** Under the ranking: the editorial pick is done, the directory is over here. */
23
+ export const Row: Story = {
24
+ args: {
25
+ title: "This is the editorial pick — the rest is browsable",
26
+ description:
27
+ "Two hundred and forty-seven Italian kitchens, filterable by open now, price, district and what they have on.",
28
+ cta: "Browse all 247",
29
+ href: "/menu/cuisines/italian",
30
+ },
31
+ decorators: [
32
+ (Story) => (
33
+ <div className="w-[900px] bg-background p-4">
34
+ <Story />
35
+ </div>
36
+ ),
37
+ ],
38
+ };
39
+
40
+ /**
41
+ * The long CTA that `h-11` used to clip. The categories registry hands this
42
+ * component whole headlines, which is the point — the reader lands on a page
43
+ * whose `h1` is the string they tapped.
44
+ */
45
+ export const RowLongCta: Story = {
46
+ args: {
47
+ ...Row.args,
48
+ cta: "Vezi toate localurile cu shaorma din Cluj-Napoca",
49
+ },
50
+ decorators: [
51
+ (Story) => (
52
+ <div className="w-[375px] bg-background p-4">
53
+ <Story />
54
+ </div>
55
+ ),
56
+ ],
57
+ };
58
+
59
+ /** Beside a browse list, in a 380px column. */
60
+ export const Panel: Story = {
61
+ args: {
62
+ variant: "panel",
63
+ kicker: "RANKED",
64
+ title: "299 kitchens. One answer.",
65
+ description:
66
+ "Every one graded and put in order, with the reviews that decided the ranking quoted. Updated September 2026.",
67
+ cta: "The best Romanian restaurants in Cluj-Napoca",
68
+ href: "/top/romanesc",
69
+ gradient: mockGradients.pasta,
70
+ },
71
+ decorators: [
72
+ (Story) => (
73
+ <div className="w-[380px] bg-background p-4">
74
+ <Story />
75
+ </div>
76
+ ),
77
+ ],
78
+ };
79
+
80
+ /** No ground — the panel still reads as a card. */
81
+ export const PanelPlain: Story = {
82
+ args: { ...Panel.args, gradient: undefined },
83
+ decorators: Panel.decorators,
84
+ };
@@ -0,0 +1,171 @@
1
+ import { ArrowRight, LayoutList, Trophy } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
4
+
5
+ /**
6
+ * TopCrossLink — the link between the ranking and the browsing tool.
7
+ *
8
+ * The product has two answers to "where should I eat", and they are different
9
+ * questions: the intent page answers *which venues, right now*, and `/top`
10
+ * answers *which are the best*. A reader who wants the other one should be told
11
+ * it exists rather than left to bounce, and told in the same object in both
12
+ * directions — so this is one component with two proportions, not two
13
+ * components that will drift.
14
+ *
15
+ * It was `TopExitRow`, which named only the outbound half. A menu page
16
+ * importing "exit row" to point INTO the ranking is a lie in the import list,
17
+ * and four projects read these names.
18
+ *
19
+ * ## `row` under a list, `panel` beside one
20
+ *
21
+ * `row` is the way out, full width under the ranking: icon, title, prose, and a
22
+ * button on the right at `lg`. `panel` is the way in, stacked for the ~380px
23
+ * column of a browse page, and it is the one that carries a ground — a card
24
+ * competing for attention with the list next to it needs to be a place, not a
25
+ * paragraph.
26
+ *
27
+ * ## Never a sticky bar
28
+ *
29
+ * Stated once, where the reader has finished reading. A sticky call to action
30
+ * on an article is an advert, and both of these pages exist to be read to the
31
+ * end.
32
+ */
33
+
34
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
35
+ return <a href={href} {...props} />;
36
+ }
37
+
38
+ export type TopCrossLinkProps = {
39
+ /**
40
+ * `row` (default) — the way OUT of the ranking, full width under the list.
41
+ * `panel` — the way IN, stacked, for a narrow column beside a browse list.
42
+ */
43
+ variant?: "row" | "panel";
44
+ /** e.g. "RANKED". `panel` only; drawn over the title with `icon` inline. */
45
+ kicker?: string;
46
+ title: string;
47
+ description: string;
48
+ /**
49
+ * The link's text. Prefer the destination's own headline over a generic
50
+ * "see more": the reader lands on a page whose `<h1>` is the string they
51
+ * tapped, and it is the anchor text the destination actually wants.
52
+ */
53
+ cta: string;
54
+ href: string;
55
+ /** Finished CSS gradient for the ground. `panel` only. */
56
+ gradient?: string;
57
+ /** Names the DESTINATION. Defaults to `LayoutList` on `row`, `Trophy` on `panel`. */
58
+ icon?: React.ReactNode;
59
+ /** The title's element. `b` by default on `row`, `h2` on `panel`. */
60
+ as?: "h2" | "h3" | "b";
61
+ linkComponent?: MenuLinkComponent;
62
+ className?: string;
63
+ };
64
+
65
+ /**
66
+ * `min-h-11`, never `h-11`.
67
+ *
68
+ * A fixed 44px box silently clips: the categories registry hands this component
69
+ * strings like "Vezi toate localurile cu shaorma din Cluj-Napoca" (48 characters
70
+ * against ~271px of inner width at 375px), which wraps to two lines and
71
+ * overflows a fixed height. The floor plus real padding is the same 44px target
72
+ * when the label is short and grows when it is not.
73
+ */
74
+ const CTA = cn(
75
+ "rounded-xl bg-accent font-bold text-accent-foreground",
76
+ "transition-opacity duration-150 hover:opacity-90",
77
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
78
+ "motion-reduce:transition-none"
79
+ );
80
+
81
+ export function TopCrossLink({
82
+ variant = "row",
83
+ kicker,
84
+ title,
85
+ description,
86
+ cta,
87
+ href,
88
+ gradient,
89
+ icon,
90
+ as,
91
+ linkComponent: LinkComponent = DefaultLink,
92
+ className,
93
+ }: TopCrossLinkProps) {
94
+ const isPanel = variant === "panel";
95
+ const Heading = as ?? (isPanel ? "h2" : "b");
96
+ const glyph = icon ?? (isPanel ? <Trophy /> : <LayoutList />);
97
+
98
+ if (!isPanel) {
99
+ return (
100
+ <div
101
+ className={cn(
102
+ "flex flex-col gap-3 rounded-[18px] border border-border bg-card p-4",
103
+ "lg:flex-row lg:items-center lg:gap-5 lg:p-5",
104
+ className
105
+ )}
106
+ >
107
+ <span aria-hidden className="shrink-0 text-muted-foreground [&>svg]:h-5 [&>svg]:w-5">
108
+ {glyph}
109
+ </span>
110
+ <div className="min-w-0 flex-1">
111
+ <Heading className="block text-[15px] font-bold tracking-[-0.01em]">{title}</Heading>
112
+ <p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
113
+ </div>
114
+ <LinkComponent
115
+ href={href}
116
+ className={cn(
117
+ CTA,
118
+ "inline-flex min-h-11 shrink-0 items-center justify-center gap-2 px-5 py-2.5 text-sm"
119
+ )}
120
+ >
121
+ {cta}
122
+ <ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
123
+ </LinkComponent>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <div
130
+ className={cn(
131
+ "relative overflow-hidden rounded-[18px] border border-border bg-card shadow-[var(--shadow-card)]",
132
+ className
133
+ )}
134
+ >
135
+ {/* An inset span rather than a background on the card, so the ground can
136
+ be a finished gradient string without fighting `bg-card` for the
137
+ shorthand — the same construction `TopRankingHero` uses. */}
138
+ {gradient && (
139
+ <span aria-hidden className="absolute inset-0" style={{ backgroundImage: gradient }} />
140
+ )}
141
+
142
+ <div className="relative z-[1] p-4 lg:p-5">
143
+ {kicker && (
144
+ <span className="flex items-center gap-1.5 text-[10.5px] font-extrabold uppercase tracking-[0.13em] text-muted-foreground">
145
+ <span aria-hidden className="[&>svg]:h-3 [&>svg]:w-3 [&>svg]:shrink-0">{glyph}</span>
146
+ {kicker}
147
+ </span>
148
+ )}
149
+
150
+ {/* Serif, and a tier under the page's own h1. The card is a window onto
151
+ the ranking, so it speaks in the destination's voice. */}
152
+ <Heading className="mt-2 block font-serif text-[20px] font-bold leading-[1.12] tracking-[-0.02em] text-foreground lg:text-[21px]">
153
+ {title}
154
+ </Heading>
155
+
156
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
157
+
158
+ <LinkComponent
159
+ href={href}
160
+ className={cn(
161
+ CTA,
162
+ "mt-4 flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[13.5px] leading-snug"
163
+ )}
164
+ >
165
+ <span>{cta}</span>
166
+ <ArrowRight className="h-4 w-4 shrink-0" aria-hidden />
167
+ </LinkComponent>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,171 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TopRankingEntry } from "./top-ranking-entry";
3
+ import type { TopRankEntry } from "./top-types";
4
+ import type { MenuItemPreview } from "./menu-types";
5
+
6
+ const labels = {
7
+ signature: "Signature",
8
+ openDish: (name: string) => `See ${name}`,
9
+ itemHref: (item: MenuItemPreview) =>
10
+ item.slug ? `/menu/${item.kind === "drink" ? "drinks" : "dishes"}/${item.slug}` : undefined,
11
+ item: {
12
+ currency: "RON",
13
+ mentions: (count: number) => `${count} mentions`,
14
+ signature: "SIGNATURE",
15
+ mentioned: "MENTIONED",
16
+ },
17
+ };
18
+
19
+ const entry: TopRankEntry = {
20
+ rank: 1,
21
+ slug: "salt-and-stone",
22
+ name: "Salt & Stone",
23
+ href: "/places/salt-and-stone",
24
+ district: "Centru",
25
+ gradeLabel: "9.4",
26
+ tierLabel: "Exceptional",
27
+ editorial:
28
+ "The tagliatelle is the reason to book a week ahead — the truffle is shaved at the table and the pasta is made that morning.",
29
+ menuGroupLabel: "Pasta",
30
+ menuItems: [
31
+ {
32
+ id: "m1",
33
+ slug: "truffle-tagliatelle",
34
+ name: "Truffle Tagliatelle",
35
+ kind: "dish" as const,
36
+ description: "Fresh pasta, black truffle, parmesan.",
37
+ mentions: 17,
38
+ sizes_and_prices: [{ size: 320, price: 62 }],
39
+ },
40
+ {
41
+ id: "m2",
42
+ slug: "classic-tiramisu",
43
+ name: "Classic Tiramisù",
44
+ kind: "dish" as const,
45
+ mentions: 9,
46
+ sizes_and_prices: [{ size: 180, price: 32 }],
47
+ },
48
+ ],
49
+ evidence: {
50
+ quote: "Best carbonara in the city and they will tell you off, politely, if you ask for cream.",
51
+ sourceLabel: "17 from reviews",
52
+ },
53
+ };
54
+
55
+ const meta: Meta<typeof TopRankingEntry> = {
56
+ title: "Top/TopRankingEntry",
57
+ component: TopRankingEntry,
58
+ tags: ["autodocs"],
59
+ args: {
60
+ entry,
61
+ labels,
62
+ formatPrice: (amount: number) => `${amount} RON`,
63
+ markGradient: "linear-gradient(150deg, oklch(0.56 0.15 38), oklch(0.46 0.14 34))",
64
+ },
65
+ decorators: [
66
+ (Story) => (
67
+ <div className="w-[1000px] bg-background p-4">
68
+ <Story />
69
+ </div>
70
+ ),
71
+ ],
72
+ parameters: {
73
+ docs: {
74
+ description: {
75
+ component:
76
+ "A numbered editorial entry, not a card. Cards imply a grid you filter and force every entry into the same height — wrong when one kitchen has a three-line quote and another has a sentence. **Evidence leads**: the cited review is the entry's first content, because a rank is a claim and the page has to show why before it asks you to accept the position. The entry goes to the venue; the signature dish opens the drawer.",
77
+ },
78
+ },
79
+ },
80
+ };
81
+
82
+ export default meta;
83
+ type Story = StoryObj<typeof TopRankingEntry>;
84
+
85
+ export const Default: Story = {};
86
+
87
+ /** No grade yet — the third-party star is the fallback, never a blank. */
88
+ export const Ungraded: Story = {
89
+ args: {
90
+ entry: { ...entry, gradeLabel: undefined, tierLabel: undefined, rating: 4.6 },
91
+ },
92
+ };
93
+
94
+ /** A must-try with no live menu row: `MenuItem` renders it inert, not linked. */
95
+ export const InertDish: Story = {
96
+ args: {
97
+ entry: {
98
+ ...entry,
99
+ menuItems: [{ ...entry.menuItems![0], slug: "", sizes_and_prices: undefined }],
100
+ },
101
+ },
102
+ };
103
+
104
+ /** No evidence, no editorial, no menu — the entry is still a legible rank. */
105
+ export const Bare: Story = {
106
+ args: {
107
+ entry: { ...entry, editorial: undefined, evidence: undefined, menuItems: undefined },
108
+ },
109
+ };
110
+
111
+ /**
112
+ * On the phone the rank, name, district and grade sit INSIDE a 3:2 cover under
113
+ * a scrim, and the cover halves. It is one DOM copy placed in the same grid
114
+ * cell, not a `hidden`/`lg:hidden` twin — 25 duplicated headings and venue
115
+ * links is not something to ship on the page whose value is search.
116
+ */
117
+ export const Mobile: Story = {
118
+ decorators: [
119
+ (Story) => (
120
+ <div className="w-[390px] bg-background p-3">
121
+ <Story />
122
+ </div>
123
+ ),
124
+ ],
125
+ };
126
+
127
+ /**
128
+ * The contrast worst case: a blown-out cover. The scrim's ramp is anchored in
129
+ * PIXELS to the block's `pt-12` runway, so the first glyph always lands at
130
+ * alpha 0.72 — 7.3:1 on the name against pure white — whatever the handset
131
+ * width or how the name wraps.
132
+ */
133
+ export const MobileBrightCover: Story = {
134
+ ...Mobile,
135
+ args: {
136
+ entry: {
137
+ ...entry,
138
+ coverUrl:
139
+ "data:image/svg+xml;utf8," +
140
+ encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#ffffff"/></svg>'),
141
+ },
142
+ },
143
+ };
144
+
145
+ /** A name that wraps. It clamps at two lines and the row stays baseline-aligned. */
146
+ export const MobileLongName: Story = {
147
+ ...Mobile,
148
+ args: {
149
+ entry: { ...entry, name: "PASQUALE | brunch & dinner, Piața Unirii" },
150
+ },
151
+ };
152
+
153
+ /**
154
+ * No photograph and no grade at once: the warm ground with the venue's initial
155
+ * — never a grey box — and the filled star fallback. The desktop keeps its
156
+ * diagonal stripes, which are a LIGHT surface and would put white type on a
157
+ * pale rectangle at this size.
158
+ */
159
+ export const MobileNoPhotoNoGrade: Story = {
160
+ ...Mobile,
161
+ args: {
162
+ entry: {
163
+ ...entry,
164
+ coverUrl: undefined,
165
+ gradeLabel: undefined,
166
+ tierLabel: undefined,
167
+ rating: 4.6,
168
+ },
169
+ },
170
+ };
171
+
@@ -0,0 +1,105 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TopRankingEntry } from "./top-ranking-entry";
4
+ import type { TopRankEntry } from "./top-types";
5
+
6
+ const labels = {
7
+ signature: "Signature",
8
+ openDish: (name: string) => `See ${name}`,
9
+ itemHref: () => undefined,
10
+ item: {
11
+ currency: "RON",
12
+ mentions: (count: number) => `${count} mentions`,
13
+ signature: "SIGNATURE",
14
+ mentioned: "MENTIONED",
15
+ },
16
+ };
17
+
18
+ const entry: TopRankEntry = {
19
+ rank: 1,
20
+ slug: "pasquale",
21
+ name: "PASQUALE | brunch & dinner",
22
+ href: "/places/pasquale",
23
+ district: "Centru",
24
+ gradeLabel: "9.4",
25
+ tierLabel: "Exceptional",
26
+ coverUrl: "https://example.test/cover.jpg",
27
+ editorial: "Locals keep coming back.",
28
+ };
29
+
30
+ function renderEntry(override: Partial<TopRankEntry> = {}) {
31
+ return render(
32
+ <TopRankingEntry
33
+ entry={{ ...entry, ...override }}
34
+ labels={labels}
35
+ formatPrice={(amount) => `${amount} RON`}
36
+ />
37
+ );
38
+ }
39
+
40
+ /**
41
+ * The phone overlay puts white type over a photograph, so its legibility rests
42
+ * entirely on the scrim behind it. That scrim is a long arbitrary-value class
43
+ * passed through `cn()` — i.e. through `tailwind-merge`, which is allowed to
44
+ * DROP a class it believes a later one supersedes. If it ever does, nothing
45
+ * errors: the type just goes white-on-white, which is precisely how this
46
+ * shipped twice.
47
+ */
48
+ describe("the phone overlay", () => {
49
+ it("keeps the scrim on the identity block after class merging", () => {
50
+ renderEntry();
51
+ const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
52
+ expect(block?.className).toContain("linear-gradient(180deg,oklch(0.16_0.012_55_/_0)");
53
+ // …and clears it again at desktop, where the block is on paper.
54
+ expect(block?.className).toContain("lg:bg-none");
55
+ });
56
+
57
+ it("places the identity block in the cover's grid cell", () => {
58
+ renderEntry();
59
+ const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
60
+ expect(block?.className).toContain("col-start-1");
61
+ expect(block?.className).toContain("row-start-1");
62
+ expect(block?.className).toContain("self-end");
63
+ });
64
+
65
+ /*
66
+ * A non-positioned box's background paints in an earlier phase than replaced
67
+ * inline content, so an unpositioned overlay puts its scrim UNDER the
68
+ * photograph — the scrim vanishes, the white type lands on bare image, and
69
+ * nothing anywhere reports an error.
70
+ */
71
+ it("positions the identity block so its scrim paints above the photograph", () => {
72
+ renderEntry();
73
+ const block = screen.getByRole("heading", { level: 3 }).closest("div")?.parentElement;
74
+ expect(block?.className).toContain("relative");
75
+ expect(block?.className).toContain("z-[1]");
76
+ });
77
+
78
+ /*
79
+ * Every mobile-only style here is written base-first and undone with `lg:`.
80
+ * `max-lg:` is unusable in this project — the consuming app's Tailwind emits
81
+ * no `max-width` media queries at all, so those classes are dropped silently
82
+ * and the style simply never exists.
83
+ */
84
+ it("uses no max-lg variants, which this project cannot compile", () => {
85
+ const { container } = renderEntry();
86
+ expect(container.innerHTML).not.toContain("max-lg:");
87
+ });
88
+
89
+ it("renders one heading and one venue link per entry, not a mobile twin", () => {
90
+ renderEntry();
91
+ expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(1);
92
+ expect(screen.getAllByRole("link", { name: entry.name })).toHaveLength(1);
93
+ });
94
+
95
+ it("keeps the band word beside the grade rather than hiding it", () => {
96
+ renderEntry();
97
+ expect(screen.getByText("Exceptional")).toBeInTheDocument();
98
+ expect(screen.getByText("9.4")).toBeInTheDocument();
99
+ });
100
+
101
+ it("falls back to the venue's initial when there is no photograph", () => {
102
+ renderEntry({ coverUrl: undefined });
103
+ expect(screen.getByText("P")).toBeInTheDocument();
104
+ });
105
+ });