@bearmenu/ui 0.8.7 → 0.8.9

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 (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -0,0 +1,211 @@
1
+ import { Wallet } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
4
+ import type { MenuFamilyPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
5
+
6
+ /**
7
+ * MenuFamilyCard — a browsable grouping of venues: a cuisine on the dishes tab,
8
+ * a drink family on the drinks tab.
9
+ *
10
+ * ## The count does the scanning, the item answers the question
11
+ *
12
+ * The card is two halves with two jobs. The dark panel is for scanning a row of
13
+ * four: one number, one name, and the groups the family consists of, over a
14
+ * mosaic of its venues. The white half underneath answers what you would
15
+ * actually order — the production `<MenuItem>`, with its price, portion and
16
+ * mention count, so the recommendation is the review census rather than an
17
+ * editorial pick.
18
+ *
19
+ * ## Both halves count VENUES
20
+ *
21
+ * Cuisine exists on places and nowhere else — no listing endpoint will tell you
22
+ * how many dishes are Italian — so a card counting dishes cannot be built and
23
+ * this one does not try. The groups line is a plain list without counts: at
24
+ * four cards across, four chips each carrying a number turned the panel into a
25
+ * table, and the numbers were kitchens, which nobody reads a chip as.
26
+ *
27
+ * The mosaic holds VENUE photographs, which are real. It dims them hard — this
28
+ * is a ground for a number, not a gallery, and at this size a legible photo
29
+ * competes with the count for the same glance.
30
+ */
31
+
32
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
33
+ export type MenuFamilyCardLinkProps = MenuLinkProps;
34
+
35
+ function DefaultLink({ href, ...props }: MenuFamilyCardLinkProps) {
36
+ return <a href={href} {...props} />;
37
+ }
38
+
39
+ export type MenuFamilyCardLabels = {
40
+ /** Unit under the count, e.g. "KITCHENS". */
41
+ venueUnit: string;
42
+ /** Kicker over the featured item, e.g. "MOST NAMED". */
43
+ mostNamed: string;
44
+ /** Footer caption, e.g. "kitchen price band". */
45
+ priceBand: string;
46
+ };
47
+
48
+ export type MenuFamilyCardProps = {
49
+ family: MenuFamilyPreview;
50
+ labels: MenuFamilyCardLabels;
51
+ /** Passed through to the featured `<MenuItem>`. */
52
+ itemLabels: MenuItemLabels;
53
+ formatPrice: (amount: number) => string;
54
+ formatCount?: (value: number) => string;
55
+ href: string;
56
+ /** Finished CSS colour for the featured item's category mark. */
57
+ hue?: string;
58
+ /** Composed price band, e.g. "32–84 lei". */
59
+ priceBandLabel?: string;
60
+ linkComponent?: MenuLinkComponent;
61
+ className?: string;
62
+ };
63
+
64
+ /**
65
+ * A 2×2, not a 2×4.
66
+ *
67
+ * Four cells at card width give each photograph enough room to read as a room
68
+ * rather than as a swatch, and — the reason it changed — four cells match the
69
+ * four venues a caller actually has, so no photo repeats. At eight cells the
70
+ * list wrapped and put the SAME image directly above itself in a column, which
71
+ * is the one repeat a mosaic cannot hide.
72
+ */
73
+ const MOSAIC_CELLS = 4;
74
+
75
+ export function MenuFamilyCard({
76
+ family,
77
+ labels,
78
+ itemLabels,
79
+ formatPrice,
80
+ formatCount,
81
+ href,
82
+ hue,
83
+ priceBandLabel,
84
+ linkComponent: LinkComponent = DefaultLink,
85
+ className,
86
+ }: MenuFamilyCardProps) {
87
+ const photos = family.venue_photo_urls ?? [];
88
+ const groups = (family.chips ?? []).map((chip) => chip.label);
89
+
90
+ return (
91
+ <div
92
+ className={cn(
93
+ "flex h-full flex-col overflow-hidden rounded-[14px] border border-border bg-card",
94
+ className
95
+ )}
96
+ >
97
+ <LinkComponent
98
+ href={href}
99
+ className="relative block overflow-hidden bg-[oklch(0.21_0.012_55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
100
+ >
101
+ <span aria-hidden className="absolute inset-0 grid grid-cols-2 grid-rows-2 gap-px">
102
+ {Array.from({ length: MOSAIC_CELLS }, (_, cell) =>
103
+ photos.length ? (
104
+ // eslint-disable-next-line @next/next/no-img-element
105
+ <img
106
+ key={cell}
107
+ src={photos[cell % photos.length]}
108
+ alt=""
109
+ loading="lazy"
110
+ decoding="async"
111
+ className="h-full w-full object-cover"
112
+ />
113
+ ) : (
114
+ <span
115
+ key={cell}
116
+ className="bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.18]"
117
+ />
118
+ )
119
+ )}
120
+ </span>
121
+
122
+ {/*
123
+ The family's colour over the photography, when the caller passes a
124
+ `hue`. `mix-blend-mode: color` keeps every photo's luminance and
125
+ replaces its chroma with the family's, so four cards of four
126
+ different rooms read as one cocktail-blue, one wine-red, one
127
+ beer-gold, one coffee-brown — the same dictionary the chips and the
128
+ price band already use (`markGradient`). The scrim below still owns
129
+ the contrast; this layer only says which family the room belongs to.
130
+ */}
131
+ {hue && (
132
+ <span
133
+ aria-hidden
134
+ className="absolute inset-0 opacity-90 mix-blend-color"
135
+ style={{ backgroundColor: hue }}
136
+ />
137
+ )}
138
+
139
+ {/*
140
+ The hero's scrim, not a light tint.
141
+ The count and the name sit at opposite ends of this panel, so both
142
+ ends need to be dark — a gradient that clears in the middle looked
143
+ better empty and made the type unreadable the moment there were real
144
+ photographs behind it. Dimming the images themselves was tried first
145
+ and is worse: it greys the photography without ever guaranteeing
146
+ contrast, because the guarantee has to come from a fixed layer.
147
+ */}
148
+ <span
149
+ aria-hidden
150
+ className="absolute inset-0 bg-[linear-gradient(180deg,oklch(0.16_0.012_55_/_0.9)_0%,oklch(0.16_0.012_55_/_0.62)_45%,oklch(0.16_0.012_55_/_0.94)_100%)]"
151
+ />
152
+
153
+ {/* Taller than the half below it: the photography is what makes a row
154
+ of four scannable, and it was losing the card to a text block. */}
155
+ <span className="relative flex min-h-[260px] flex-col justify-between p-3.5 text-white">
156
+ <span className="block">
157
+ <b className="block text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
158
+ {family.venue_count}
159
+ </b>
160
+ <span className="mt-1 block text-[9.5px] font-bold tracking-[0.12em] opacity-80">
161
+ {labels.venueUnit}
162
+ </span>
163
+ </span>
164
+
165
+ <span className="block">
166
+ <span className="block text-[17px] font-bold tracking-[-0.01em]">{family.name}</span>
167
+ {groups.length > 0 && (
168
+ <span className="mt-0.5 block truncate text-[11.5px] opacity-75">
169
+ {groups.join(" · ")}
170
+ </span>
171
+ )}
172
+
173
+ {/* The band belongs up here with the other facts ABOUT the family.
174
+ Below, it was a third block competing with the one thing that
175
+ half is for — the item you might actually order. */}
176
+ {priceBandLabel && (
177
+ <span className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[11px] backdrop-blur-[2px]">
178
+ <Wallet className="h-3 w-3 shrink-0" aria-hidden />
179
+ <span className="opacity-80">{labels.priceBand}</span>
180
+ <b className="font-semibold tabular-nums">{priceBandLabel}</b>
181
+ </span>
182
+ )}
183
+ </span>
184
+ </span>
185
+ </LinkComponent>
186
+
187
+ {family.signature && (
188
+ <div className="flex flex-1 flex-col px-2 pb-2 pt-3">
189
+ <span className="px-2 text-[9.5px] font-bold uppercase tracking-[0.09em] text-muted-foreground">
190
+ {labels.mostNamed}
191
+ </span>
192
+
193
+ {/* MenuItem's own `px-4`: its colour mark is drawn at `-ml-4` and
194
+ needs that gutter — a tighter padding clipped it to a grey stub. */}
195
+ <MenuItem
196
+ className="min-h-0"
197
+ item={family.signature.item}
198
+ labels={itemLabels}
199
+ formatPrice={formatPrice}
200
+ formatCount={formatCount}
201
+ markGradient={hue}
202
+ subtitle={family.signature.why}
203
+ href={family.signature.href ?? href}
204
+ linkComponent={LinkComponent}
205
+ />
206
+
207
+ </div>
208
+ )}
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuField } from "./menu-field";
3
+ import { mockGradients } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof MenuField> = {
6
+ title: "Menu/MenuField",
7
+ component: MenuField,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The photoless cover for a dish or drink: the item's own name repeating across a shallow diagonal over a gradient. Menu-item photography is AI-generated and never shown, so the type IS the cover. Rows fade symmetrically out from one strong centre line, which is what keeps them reading as texture in the colour rather than as a watermark stamped on top of it. The gradient is injected because resolving a category to a hue needs app-owned enums.",
14
+ },
15
+ },
16
+ },
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof MenuField>;
21
+
22
+ export const Default: Story = {
23
+ args: {
24
+ name: "Truffle Tagliatelle",
25
+ gradient: mockGradients.pasta,
26
+ className: "h-[200px] w-[260px] rounded-xl",
27
+ },
28
+ };
29
+
30
+ /** A long name repeats fewer times per line, so the texture opens up. */
31
+ export const LongName: Story = {
32
+ args: {
33
+ name: "Burrata & heirloom tomato with basil oil",
34
+ gradient: mockGradients.greens,
35
+ className: "h-[200px] w-[260px] rounded-xl",
36
+ },
37
+ };
38
+
39
+ /** Short names tile densely — the falloff is what stops that reading as noise. */
40
+ export const ShortName: Story = {
41
+ args: {
42
+ name: "Mici",
43
+ gradient: mockGradients.meat,
44
+ className: "h-[200px] w-[260px] rounded-xl",
45
+ },
46
+ };
47
+
48
+ /**
49
+ * `seedKey` fixes the horizontal stagger. Two cards for the same concept at
50
+ * different venues break identically; pass the item slug and they diverge.
51
+ */
52
+ export const EveryHue: Story = {
53
+ render: () => (
54
+ <div className="flex flex-wrap gap-3">
55
+ {Object.entries(mockGradients).map(([name, gradient]) => (
56
+ <MenuField
57
+ key={name}
58
+ name={name}
59
+ gradient={gradient}
60
+ className="h-[120px] w-[150px] rounded-xl"
61
+ />
62
+ ))}
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ /** At tile scale the type has to survive being small. This is the real size. */
68
+ export const AtTileScale: Story = {
69
+ args: {
70
+ name: "Chicken wings, 6 pc",
71
+ gradient: mockGradients.meat,
72
+ className: "aspect-[5/4] w-[180px] rounded-xl",
73
+ },
74
+ };
@@ -0,0 +1,199 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * MenuField — the photoless cover surface for a dish or drink.
5
+ *
6
+ * Menu-item photography is AI-generated and never shown, so the item's own name
7
+ * repeats across a shallow diagonal over a gradient. The type IS the cover.
8
+ *
9
+ * The rows fade SYMMETRICALLY out from a single strong row at the centre. That
10
+ * shape is what keeps the type reading as texture in the colour rather than as
11
+ * a watermark stamped on top of it: the eye lands on one legible line and the
12
+ * rest falls away in both directions. A uniform wash — or a random per-row
13
+ * alpha — reads as noise at every alpha high enough to see.
14
+ *
15
+ * The alpha curve is IDENTICAL for every hue. Scaling it per hue to even out
16
+ * how white type reads over different grounds also moves where rows fall below
17
+ * visibility, so a damped hue lost rows and one card showed five bands where
18
+ * its neighbour showed three. Correct contrast in the `gradient` you pass, not
19
+ * in the curve.
20
+ *
21
+ * ## Why `gradient` is a prop
22
+ *
23
+ * Resolving a category slug to a hue needs the app's category enums, which the
24
+ * DS does not take. Callers pass a finished CSS gradient — fe from its own
25
+ * category-colour table, post-generator from whatever it is filming. The DS
26
+ * owns the typography and the falloff; the consumer owns the palette.
27
+ *
28
+ * Server-safe: no state, no effects. The row geometry is a 13-iteration loop
29
+ * computed on every render, which is cheaper than the hook that would let it be
30
+ * skipped.
31
+ */
32
+
33
+ export type MenuFieldProps = {
34
+ /** Item name — rendered as the repeating ghost. */
35
+ name: string;
36
+ /** CSS `background-image` value. See the note above on why this is injected. */
37
+ gradient: string;
38
+ /**
39
+ * Deterministic stagger seed. Pass the PARENT CONCEPT SLUG so one dish breaks
40
+ * its lines identically at every venue and across re-sorts. Defaults to
41
+ * `name`, which is stable but shared by every venue's version of a dish.
42
+ */
43
+ seedKey?: string;
44
+ /**
45
+ * `repeat` (default) — the name as texture, thirteen fading rows. Right at
46
+ * swatch and thumbnail sizes, where any single line would be unreadable.
47
+ *
48
+ * `display` — the name ONCE, set large and bled off the edges. Right when the
49
+ * tile is the primary object on its row and the name is the thing being sold;
50
+ * the repeat treatment at that size reads as wallpaper rather than as a dish.
51
+ * Long names clip on purpose — the caption underneath carries the full text.
52
+ */
53
+ variant?: "repeat" | "display";
54
+ className?: string;
55
+ };
56
+
57
+ /**
58
+ * Odd, so there is a true middle row to peak on. Fixed rather than measured:
59
+ * the field is only ever 106–260px tall, 13 rows covers the tallest of those
60
+ * once the 11° rotation and the 180% oversize are accounted for, and the rest
61
+ * is clipped. Measuring would cost a layout effect per card on a grid that
62
+ * renders fifty at a time.
63
+ */
64
+ const GHOST_ROWS = 13;
65
+ const GHOST_CENTRE = (GHOST_ROWS - 1) / 2;
66
+
67
+ /**
68
+ * Alpha at the centre row and at the outermost rows.
69
+ *
70
+ * The peak has to clear the point where white stops reading as white. White at
71
+ * a low alpha over a saturated field does not composite to a pale version of
72
+ * itself — it composites to a desaturated version of the FIELD, which the eye
73
+ * calls grey. At 0.30 the brightest rows read as grey text on colour; the type
74
+ * only resolves as an off-white shade of the field once the centre gets enough
75
+ * weight to pull lightness up and chroma down.
76
+ */
77
+ const ALPHA_PEAK = 0.44;
78
+ const ALPHA_EDGE = 0;
79
+
80
+ /**
81
+ * Falloff steepness. High on purpose: only the middle band should read, with
82
+ * everything above and below dropping to nothing well before the card edge. At
83
+ * 1.6 the outer rows were still legible and the field looked tiled rather than
84
+ * lit; 1 would be a flat linear ramp leaving every row half-visible.
85
+ */
86
+ const FALLOFF_EXPONENT = 3.4;
87
+
88
+ /** Target line width in characters, so short names repeat more than long ones. */
89
+ const GHOST_LINE_CHARS = 56;
90
+
91
+ /** djb2. Same input, same break pattern, always — and no `Math.random` to hydrate-mismatch. */
92
+ function hashString(input: string): number {
93
+ let hash = 5381;
94
+ for (let i = 0; i < input.length; i++) hash = (hash * 33) ^ input.charCodeAt(i);
95
+ return Math.abs(hash);
96
+ }
97
+
98
+ export function MenuField({
99
+ name,
100
+ gradient,
101
+ seedKey,
102
+ variant = "repeat",
103
+ className,
104
+ }: MenuFieldProps) {
105
+ if (variant === "display") {
106
+ // Fit the name to the tile. The longest word sets the width the type may
107
+ // take (a `font-black` uppercase glyph advances ~0.62em on average), the
108
+ // word count sets the height (leading 0.9). Whichever is tighter wins,
109
+ // capped at the 19cqw a short name used to get and floored at 9cqw so a
110
+ // very long name becomes small type, not clipped type. A little bleed past
111
+ // the padding is fine; three lines of letters taller than the tile is not.
112
+ const words = name.trim().split(/\s+/).filter(Boolean);
113
+ const longestWord = Math.max(1, ...words.map((word) => word.length));
114
+ const lineCount = Math.max(1, words.length);
115
+ const byWidth = (96 / (longestWord * 0.62)).toFixed(1);
116
+ const byHeight = (90 / (lineCount * 0.9)).toFixed(1);
117
+ const fontSize = `clamp(9cqw, min(19cqw, ${byWidth}cqw, ${byHeight}cqh), 19cqw)`;
118
+ return (
119
+ <div
120
+ className={cn("relative isolate overflow-hidden", className)}
121
+ // `size` containment (not the wrapper's inline-size) so `cqh` resolves
122
+ // against THIS tile's height. Safe: the field is sized by its parent's
123
+ // aspect box, never by its own content.
124
+ style={{ backgroundImage: gradient, containerType: "size" }}
125
+ >
126
+ {/*
127
+ The name ONCE, in translucent white, sized to FIT the tile — see the
128
+ arithmetic above. `cqw`/`cqh` units so the type scales with the card
129
+ instead of being a fixed pixel size that looks right in a six-across
130
+ grid and absurd anywhere else. The caption below carries the full
131
+ text, which is also why this is `aria-hidden`.
132
+
133
+ Centred on both axes so a short name grows symmetrically and a long
134
+ one stacks its words in the middle. NO `break-words`: it split names
135
+ mid-glyph ("PIZZA NEW CROC / O"), which reads as a rendering fault.
136
+ Words wrap whole.
137
+ */}
138
+ <span
139
+ aria-hidden
140
+ className="pointer-events-none absolute inset-0 flex select-none items-center justify-center p-[4cqw] text-center font-black uppercase leading-[0.9] tracking-[-0.03em] text-white/[0.26]"
141
+ style={{ fontSize }}
142
+ >
143
+ {name}
144
+ </span>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ const unit = `${name} · `;
150
+ const repeats = Math.max(2, Math.ceil(GHOST_LINE_CHARS / Math.max(unit.length, 1)));
151
+ const ghostLine = unit.repeat(repeats);
152
+ const seed = hashString(seedKey ?? name);
153
+
154
+ const rows = Array.from({ length: GHOST_ROWS }, (_, row) => {
155
+ const distance = Math.abs(row - GHOST_CENTRE) / GHOST_CENTRE;
156
+ const alpha = ALPHA_EDGE + (ALPHA_PEAK - ALPHA_EDGE) * Math.pow(1 - distance, FALLOFF_EXPONENT);
157
+ return { row, alpha, offset: ((seed >> (row % 12)) % 90) - 45 };
158
+ });
159
+
160
+ return (
161
+ <div
162
+ className={cn("relative isolate overflow-hidden", className)}
163
+ style={{ backgroundImage: gradient }}
164
+ >
165
+ {/*
166
+ `aria-hidden`: the ghost duplicates the caption beside it, and a screen
167
+ reader announcing "Arancini" thirteen times is noise. `select-none` so
168
+ dragging across a grid does not lift a wall of repeated names into the
169
+ clipboard.
170
+ */}
171
+ <div
172
+ aria-hidden
173
+ className="pointer-events-none absolute left-1/2 top-1/2 flex h-[180%] w-[180%] -translate-x-1/2 -translate-y-1/2 -rotate-[11deg] select-none flex-col justify-center"
174
+ >
175
+ {rows.map(({ row, alpha, offset }) => (
176
+ <span
177
+ key={row}
178
+ className="block whitespace-nowrap text-center text-[15px] font-bold leading-[1.45] tracking-[-0.01em]"
179
+ style={{
180
+ color: `oklch(1 0 0 / ${alpha.toFixed(3)})`,
181
+ // Emboss, both edges scaled by this row's alpha: a dark edge below
182
+ // cuts the letterform into the field, a light edge above lifts its
183
+ // top. The dark edge is kept well under the fill — at parity it
184
+ // ringed every glyph and dragged the type back toward grey, which
185
+ // is the opposite of what the fill is doing.
186
+ textShadow: [
187
+ `0 1px 1px oklch(0 0 0 / ${(alpha * 0.4).toFixed(3)})`,
188
+ `0 -1px 0 oklch(1 0 0 / ${(alpha * 0.4).toFixed(3)})`,
189
+ ].join(", "),
190
+ transform: `translateX(${offset}px)`,
191
+ }}
192
+ >
193
+ {ghostLine}
194
+ </span>
195
+ ))}
196
+ </div>
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ArrowLeftRight, UtensilsCrossed } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { MenuHubHero } from "./menu-hub-hero";
5
+
6
+ const meta: Meta<typeof MenuHubHero> = {
7
+ title: "Menu/MenuHubHero",
8
+ component: MenuHubHero,
9
+ tags: ["autodocs"],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[1100px] bg-background p-4">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "The claim that opens a menu hub, proven by live figures. A statement rather than a count, with the catalogue's real numbers under it and one proof line demonstrating what a catalogue can say that a single menu cannot. The ground is a texture rather than a photograph because item photography is generated and one venue's photo behind a claim about every venue argues the wrong thing — callers with a real licensed venue image can pass `backgroundUrl`.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof MenuHubHero>;
29
+
30
+ export const Default: Story = {
31
+ args: {
32
+ kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
33
+ kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
34
+ headline: "Nobody should have to walk in blind.",
35
+ body: "We read every menu in the city — what each kitchen serves, what it costs, how big the portion is, and which plates the reviews keep naming. Decide before you sit down.",
36
+ figures: [
37
+ { value: "19,062", label: "dishes" },
38
+ { value: "26,078", label: "drinks" },
39
+ { value: "397", label: "menus read" },
40
+ { value: "38", label: "cuisines" },
41
+ ],
42
+ proof: (
43
+ <span>
44
+ A margherita: <b className="font-mono font-medium">87 kitchens make one, 10 to 120 lei</b>
45
+ </span>
46
+ ),
47
+ proofIcon: <ArrowLeftRight className="h-3.5 w-3.5" />,
48
+ action: (
49
+ <Button size="sm" variant="secondary">
50
+ See the spread
51
+ </Button>
52
+ ),
53
+ },
54
+ };
55
+
56
+ /** The drinks tab: same component, different view model and copy. */
57
+ export const DrinksTab: Story = {
58
+ args: {
59
+ kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
60
+ kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
61
+ headline: "The same negroni, across seventy-four bars.",
62
+ body: "Twenty-six thousand drinks, matched into concepts so the price comparison is real.",
63
+ figures: [
64
+ { value: "26,078", label: "drinks" },
65
+ { value: "397", label: "menus read" },
66
+ { value: "74", label: "bars pour a negroni" },
67
+ ],
68
+ },
69
+ };
70
+
71
+ /** No figures, no proof — the hero degrades to a claim and nothing else. */
72
+ export const ClaimOnly: Story = {
73
+ args: { headline: "Nobody should have to walk in blind." },
74
+ };
75
+
76
+ export const Mobile: Story = {
77
+ args: {
78
+ kicker: "397 MENUS READ IN FULL",
79
+ headline: "Nobody should have to walk in blind.",
80
+ figures: [
81
+ { value: "19,062", label: "dishes" },
82
+ { value: "397", label: "menus read" },
83
+ ],
84
+ },
85
+ decorators: [
86
+ (Story) => (
87
+ <div className="w-[390px] bg-background p-3">
88
+ <Story />
89
+ </div>
90
+ ),
91
+ ],
92
+ };