@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,191 @@
1
+ import { Wallet } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { HueChip } from "./hue-chip";
4
+ import type { MenuFamilyPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
5
+ import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
6
+
7
+ /**
8
+ * MenuFamilyCard — a browsable grouping of venues: a cuisine on the dishes tab,
9
+ * a drink family on the drinks tab. The hub's doorway to a family.
10
+ *
11
+ * ## A doorway, not a summary
12
+ *
13
+ * The cuisines index's `MenuFamilyBlock` is the family's SUMMARY: photography,
14
+ * chips, a price band and three venues. This card is the same family drawn
15
+ * one step earlier, in a row of three on the hub, where the reader is choosing
16
+ * a family and not yet a venue — so it carries the same photography, the same
17
+ * chips and the same "Browse" action, and lists no venue. A venue on the hub
18
+ * card was tried and it was the first venue's signature relabelled as the
19
+ * family's most named, which was a lie at three-card width.
20
+ *
21
+ * ## The count does the scanning
22
+ *
23
+ * The card is one number, one name and the groups the family consists of,
24
+ * over a mosaic of its venues. The count is VENUES: cuisine exists on places
25
+ * and nowhere else — no listing endpoint will tell you how many dishes are
26
+ * Italian — so a card counting dishes cannot be built and this one does not
27
+ * try. The chips are plain labels: at three cards across, chips carrying
28
+ * numbers turned the panel into a table, and the numbers were kitchens, which
29
+ * nobody reads a chip as.
30
+ *
31
+ * ## One link
32
+ *
33
+ * The whole card is the link and the "Browse" pill is its visible affordance,
34
+ * not a second link — a pill inside a link is a nested anchor, and a pill that
35
+ * is the only link leaves the photography, the number and the name inert.
36
+ */
37
+
38
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
39
+ export type MenuFamilyCardLinkProps = MenuLinkProps;
40
+
41
+ function DefaultLink({ href, ...props }: MenuFamilyCardLinkProps) {
42
+ return <a href={href} {...props} />;
43
+ }
44
+
45
+ export type MenuFamilyCardLabels = {
46
+ /** Unit under the count, e.g. "KITCHENS". */
47
+ venueUnit: string;
48
+ /**
49
+ * Price-band caption, e.g. "kitchen price band". Never drawn — it names the
50
+ * pill for a screen reader, which would otherwise be read two numbers and a
51
+ * currency.
52
+ */
53
+ priceBand: string;
54
+ /** The action pill, e.g. "Browse Italian". Sits on the photography — keep it short. */
55
+ browse: string;
56
+ };
57
+
58
+ export type MenuFamilyCardProps = {
59
+ family: MenuFamilyPreview;
60
+ labels: MenuFamilyCardLabels;
61
+ href: string;
62
+ /** Finished CSS colour for the chips. */
63
+ hue?: string;
64
+ /** Finished CSS gradient — the family's colour over the mosaic. */
65
+ gradient?: string;
66
+ /** Composed price band, e.g. "32–84 lei". */
67
+ priceBandLabel?: string;
68
+ linkComponent?: MenuLinkComponent;
69
+ className?: string;
70
+ };
71
+
72
+ /**
73
+ * A 2×2, not a 2×4.
74
+ *
75
+ * Four cells at card width give each photograph enough room to read as a room
76
+ * rather than as a swatch, and four cells match the four venues a caller
77
+ * actually has, so no photo repeats. At eight cells the list wrapped and put
78
+ * the SAME image directly above itself in a column, which is the one repeat a
79
+ * mosaic cannot hide.
80
+ */
81
+ const MOSAIC_CELLS = 4;
82
+
83
+ /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
84
+ const GROUND = PHOTO_GROUND;
85
+
86
+ /**
87
+ * The frost: absent over the top half, full at the foot, where the chips and
88
+ * the pill sit. The mask is what makes it a gradient of softness rather than
89
+ * a frosted pane — the rooms stay sharp behind the number.
90
+ */
91
+ const FROST = "backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_28%,black_58%)]";
92
+
93
+ /**
94
+ * The ink. The count and the name sit at opposite ends of the card, so both
95
+ * ends are dark — a gradient that cleared in the middle looked better empty
96
+ * and made the type unreadable the moment there were real photographs behind
97
+ * it. Literal rather than `var(--scrim)`: that token lives in the app's
98
+ * stylesheet, not this package's.
99
+ */
100
+ const INK =
101
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.78)_0%,oklch(0.16_0.012_55_/_0.5)_34%,oklch(0.16_0.012_55_/_0.86)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
102
+
103
+ export function MenuFamilyCard({
104
+ family,
105
+ labels,
106
+ href,
107
+ hue,
108
+ gradient,
109
+ priceBandLabel,
110
+ linkComponent: LinkComponent = DefaultLink,
111
+ className,
112
+ }: MenuFamilyCardProps) {
113
+ const photos = family.venue_photo_urls ?? [];
114
+ /* Fewer venues than cells: the mosaic repeats rather than leaving bare
115
+ ground, because under the tint and the ink a repeated room is a texture
116
+ and an empty cell is a hole. No venues at all: one cell, the initial. */
117
+ const tiles = photos.length
118
+ ? Array.from({ length: MOSAIC_CELLS }, (_, cell) => photos[cell % photos.length])
119
+ : [undefined];
120
+
121
+ return (
122
+ <LinkComponent
123
+ href={href}
124
+ className={cn(
125
+ "group dark relative isolate flex min-h-[280px] flex-col justify-between overflow-hidden rounded-[14px] border border-border p-3.5",
126
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white",
127
+ GROUND,
128
+ className
129
+ )}
130
+ >
131
+ {/* ── Layer 1: the mosaic ─────────────────────────────────────────── */}
132
+ <PhotoGround
133
+ className="-z-30"
134
+ photos={tiles}
135
+ initials={family.name.charAt(0)}
136
+ mosaicClassName={photos.length ? "grid-cols-2 grid-rows-2" : "grid-cols-1"}
137
+ gradient={gradient}
138
+ />
139
+
140
+ {/* ── Layer 2: the frost ──────────────────────────────────────────── */}
141
+ <span aria-hidden className={cn("absolute inset-0 -z-20", FROST)} />
142
+
143
+ {/* ── Layer 3: the ink ────────────────────────────────────────────── */}
144
+ <span aria-hidden className={cn("absolute inset-0 -z-10", INK)} />
145
+
146
+ {/* ── The content, floating ───────────────────────────────────────── */}
147
+ <span className="block text-white">
148
+ <b className="block text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
149
+ {family.venue_count}
150
+ </b>
151
+ <span className="mt-1 block text-[9.5px] font-bold uppercase tracking-[0.12em] opacity-80">
152
+ {labels.venueUnit}
153
+ </span>
154
+ </span>
155
+
156
+ <span className="block">
157
+ <span className="block text-balance font-serif text-[24px] font-semibold leading-tight tracking-[-0.02em] text-white">
158
+ {family.name}
159
+ </span>
160
+
161
+ {(priceBandLabel || (family.chips?.length ?? 0) > 0) && (
162
+ <span className="mt-2.5 flex flex-wrap items-center gap-1.5">
163
+ {priceBandLabel && (
164
+ /* No hue: a fact about money, not about the family. In the dark
165
+ scope `HueChip`'s `--muted` fallback is the dark pill. */
166
+ <HueChip
167
+ className="font-bold tabular-nums"
168
+ aria-label={`${labels.priceBand}: ${priceBandLabel}`}
169
+ icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
170
+ >
171
+ {priceBandLabel}
172
+ </HueChip>
173
+ )}
174
+ {family.chips?.map((chip) => (
175
+ <HueChip key={chip.slug} hue={hue}>
176
+ {chip.label}
177
+ </HueChip>
178
+ ))}
179
+ </span>
180
+ )}
181
+
182
+ {/* The action, as a frosted pill on the photography — the block's
183
+ pill, so the two cards read as one family. A `span`, because the
184
+ card is already the link. */}
185
+ <span className="mt-3.5 inline-flex h-10 items-center rounded-full border border-white/20 bg-white/[0.12] px-4 text-[13.5px] font-bold text-white backdrop-blur-md transition-colors group-hover:bg-white/20 motion-reduce:transition-none">
186
+ {labels.browse}
187
+ </span>
188
+ </span>
189
+ </LinkComponent>
190
+ );
191
+ }
@@ -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,107 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Clock, Leaf, MapPin, Moon, Sun, Wallet } from "lucide-react";
4
+ import { MenuFilterChips, MenuFilterSummary } from "./menu-filter-chips";
5
+ import { mockFilterChips } from "./menu-mocks";
6
+
7
+ const icons: Record<string, React.ReactNode> = {
8
+ open: <Clock className="h-3 w-3" />,
9
+ price: <Wallet className="h-3 w-3" />,
10
+ terrace: <Sun className="h-3 w-3" />,
11
+ veg: <Leaf className="h-3 w-3" />,
12
+ late: <Moon className="h-3 w-3" />,
13
+ centru: <MapPin className="h-3 w-3" />,
14
+ };
15
+
16
+ const chips = mockFilterChips.map((chip) => ({ ...chip, icon: icons[chip.key] }));
17
+
18
+ const meta: Meta<typeof MenuFilterChips> = {
19
+ title: "Menu/MenuFilterChips",
20
+ component: MenuFilterChips,
21
+ tags: ["autodocs"],
22
+ decorators: [
23
+ (Story) => (
24
+ <div className="w-[1100px] bg-background p-4">
25
+ <Story />
26
+ </div>
27
+ ),
28
+ ],
29
+ parameters: {
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is pinned opposite the unit label rather than wrapping.",
34
+ },
35
+ },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof MenuFilterChips>;
41
+
42
+ function Interactive({ initial }: { initial: typeof chips }) {
43
+ const [state, setState] = useState(initial);
44
+ const active = state.filter((chip) => chip.active).length;
45
+
46
+ return (
47
+ <>
48
+ <MenuFilterChips
49
+ chips={state}
50
+ unitLabel="KITCHENS · 247"
51
+ moreLabel="More filters"
52
+ onMore={() => {}}
53
+ ariaLabel="Filter kitchens"
54
+ onToggle={(key) =>
55
+ setState((chips) =>
56
+ chips.map((chip) => (chip.key === key ? { ...chip, active: !chip.active } : chip))
57
+ )
58
+ }
59
+ />
60
+ {active > 0 && (
61
+ <MenuFilterSummary
62
+ summary={
63
+ <>
64
+ {active} filters · <b>34 of 247 kitchens</b> match
65
+ </>
66
+ }
67
+ clearLabel="Clear all"
68
+ onClear={() => setState((chips) => chips.map((c) => ({ ...c, active: false })))}
69
+ />
70
+ )}
71
+ </>
72
+ );
73
+ }
74
+
75
+ export const Default: Story = { render: () => <Interactive initial={chips} /> };
76
+
77
+ /**
78
+ * A chip that has gone empty.
79
+ *
80
+ * The facets endpoint omits values that would match nothing, so this state
81
+ * cannot arrive from the API — a chip only becomes `empty` client-side, after
82
+ * it was offered and the reader's next filter emptied it. It stays TAPPABLE:
83
+ * pressing it swaps it in and drops whichever active filter conflicts. Disabling
84
+ * it would refuse the reader the thing they asked for and give no reason.
85
+ */
86
+ export const Dead: Story = {
87
+ render: () => (
88
+ <MenuFilterChips
89
+ chips={chips.map((chip) =>
90
+ chip.key === "late" ? { ...chip, count: 0, empty: true } : chip
91
+ )}
92
+ unitLabel="KITCHENS · 247"
93
+ onToggle={() => {}}
94
+ />
95
+ ),
96
+ };
97
+
98
+ export const Mobile: Story = {
99
+ render: () => <Interactive initial={chips} />,
100
+ decorators: [
101
+ (Story) => (
102
+ <div className="w-[390px] bg-background p-3">
103
+ <Story />
104
+ </div>
105
+ ),
106
+ ],
107
+ };