@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,91 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuFamilyBlock } from "./menu-family-block";
3
+ import { mockBareFamilyBlock, mockFamilyBlock, mockFormatters, mockGradients } from "./menu-mocks";
4
+
5
+ const labels = {
6
+ venueUnit: (count: number) => `${count} kitchens`,
7
+ more: (count: number) => `+${count} more`,
8
+ priceBand: "kitchen price band",
9
+ browse: "Browse Italian",
10
+ kitchens: {
11
+ openVenue: (name: string) => `Open ${name}`,
12
+ openDish: (name: string) => `See ${name}`,
13
+ openNow: "Open now",
14
+ },
15
+ };
16
+
17
+ const meta: Meta<typeof MenuFamilyBlock> = {
18
+ title: "Menu/MenuFamilyBlock",
19
+ component: MenuFamilyBlock,
20
+ tags: ["autodocs"],
21
+ args: {
22
+ family: mockFamilyBlock,
23
+ labels,
24
+ formatCount: mockFormatters.number,
25
+ hue: "oklch(0.56 0.15 38)",
26
+ gradient: mockGradients.pasta,
27
+ },
28
+ decorators: [
29
+ (Story) => (
30
+ <div className="w-[900px] bg-background p-4">
31
+ <Story />
32
+ </div>
33
+ ),
34
+ ],
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "The cuisines index's family card. **The mosaic is the ground**: the grid of venue photographs is the background for the entire card, and everything floats on it through three layers — the mosaic wearing the family's hue, a masked `backdrop-filter` that is absent where the photography is the subject and full where the rows begin, and an ink gradient that buys the contrast frost alone cannot. Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows frosted on the right, mosaic 3×2 rather than 2×2. The content region is `.dark`-scoped, which is how `MenuKitchenList` and `HueChip` render on the ink without a fork. Two costs accepted: a masked backdrop-filter repeated ten times down a scroll, and thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof MenuFamilyBlock>;
47
+
48
+ /** A stand-in photograph with enough variance that the frost has something to soften. */
49
+ function photo(hue: number, label: string) {
50
+ return (
51
+ "data:image/svg+xml;utf8," +
52
+ encodeURIComponent(
53
+ `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="480" height="320" fill="url(#g)"/><circle cx="120" cy="90" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="260" y="180" width="180" height="110" fill="hsl(${hue} 15% 20%)" opacity=".7"/><text x="50%" y="88%" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle">${label}</text></svg>`
54
+ )
55
+ );
56
+ }
57
+
58
+ const PHOTOS = [40, 200, 90, 300, 20, 150].map((h, i) => photo(h, `venue ${i + 1}`));
59
+
60
+ export const Default: Story = {
61
+ args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
62
+ };
63
+
64
+ /**
65
+ * No photographs: the mosaic ground with the family's initial, never a grey box.
66
+ * A missing photograph is not a broken image, and a placeholder makes a real
67
+ * city look unfinished.
68
+ */
69
+ export const NoPhotos: Story = {
70
+ args: { family: { ...mockFamilyBlock, photoUrls: [] } },
71
+ };
72
+
73
+ /** No photographs, no venue, no price — one kitchen in the whole city. */
74
+ export const Bare: Story = {
75
+ args: {
76
+ family: mockBareFamilyBlock,
77
+ labels: { ...labels, browse: "Browse Portuguese" },
78
+ },
79
+ };
80
+
81
+ /** Frost and ink run top to bottom: clear photography under the name, the rows on the frosted foot. */
82
+ export const Mobile: Story = {
83
+ args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
84
+ decorators: [
85
+ (Story) => (
86
+ <div className="w-[390px] bg-background p-3">
87
+ <Story />
88
+ </div>
89
+ ),
90
+ ],
91
+ };
@@ -0,0 +1,314 @@
1
+ import { Wallet } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { HueChip } from "./hue-chip";
4
+ import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
5
+ import { Separator } from "./separator";
6
+ import { Skeleton } from "./skeleton";
7
+ import { MenuKitchenList, type MenuKitchenListLabels } from "./menu-kitchen-list";
8
+ import type {
9
+ MenuFamilyBlockPreview,
10
+ MenuLinkComponent,
11
+ MenuLinkProps,
12
+ } from "./menu-types";
13
+
14
+ /**
15
+ * MenuFamilyBlock — the cuisines index's family card. The mosaic is the ground.
16
+ *
17
+ * ## Three layers, and everything floats on them
18
+ *
19
+ * The grid of venue photographs is the background for the ENTIRE card, edge to
20
+ * edge, and the facts sit on it. Three layers do the work:
21
+ *
22
+ * 1. **The mosaic**, full-bleed, wearing the family's hue through a
23
+ * `mix-blend-color` layer.
24
+ * 2. **A masked backdrop-filter** — absent where the photography is the
25
+ * subject, full where the rows begin — so the pictures stay sharp where
26
+ * they are looked at and go soft exactly where they are read over.
27
+ * 3. **An ink gradient** over that. This is what actually buys the contrast:
28
+ * frost alone blurs without darkening, and blurred mid-tones will not carry
29
+ * 16px type.
30
+ *
31
+ * On the phone the frost and ink run top to bottom: the name and the action
32
+ * sit on clear photography, the chips and the rows on the frosted foot. On
33
+ * desktop the same three layers turn ninety degrees — the photo column on the
34
+ * left stays clear, the row column on the right is the frosted one. The mosaic
35
+ * runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
36
+ * photograph too big to read as texture.
37
+ *
38
+ * ## The dark scope
39
+ *
40
+ * The card's content region carries the `dark` class, so every token inside —
41
+ * `text-foreground`, `bg-muted`, `border-border` — resolves to its dark-theme
42
+ * value. That is how `MenuKitchenList` and `HueChip` render correctly on the
43
+ * ink without a fork or a "tone" prop: the mechanism the app already uses to
44
+ * mark a region as dark. It also means the card looks the same in both themes,
45
+ * which is right — it is built on photography, and photography is not themed.
46
+ *
47
+ * ## The two costs, stated
48
+ *
49
+ * A `backdrop-filter` over a mask is expensive to composite, and this card is
50
+ * repeated ten times down a scroll. And the thumbnails now sit on a
51
+ * photograph; they survive only because the ink is heavy under them, and on a
52
+ * busy interior shot they will fight. Both were accepted with the design.
53
+ *
54
+ * ## No "most named" item, no venue count badge
55
+ *
56
+ * The family-level "most named" was the first venue's signature relabelled —
57
+ * Italian, Pizza and Mediterranean all claimed "Turkish Poached Eggs" — and
58
+ * the count used to print three times with two values. One count, under the
59
+ * name; the overflow tile says only how many photographs it stands for.
60
+ */
61
+
62
+ /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
63
+ export type MenuFamilyBlockLinkProps = MenuLinkProps;
64
+
65
+ function DefaultLink({ href, ...props }: MenuFamilyBlockLinkProps) {
66
+ return <a href={href} {...props} />;
67
+ }
68
+
69
+ export type MenuFamilyBlockLabels = {
70
+ /** Unit under the family name, e.g. "247 kitchens". Pluralised by the caller. */
71
+ venueUnit: (count: number) => string;
72
+ /** The overflow pill over the mosaic, e.g. "+311 more". */
73
+ more: (count: number) => string;
74
+ /**
75
+ * Price-band caption, e.g. "kitchen price band".
76
+ *
77
+ * Never drawn: the band reads as a wallet and a range, and a caption on a
78
+ * pill that short is longer than the fact. It names the pill for a screen
79
+ * reader, which would otherwise be read two numbers and a currency.
80
+ */
81
+ priceBand: string;
82
+ /** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
83
+ browse: string;
84
+ /** Passed through to the venue rows. */
85
+ kitchens: MenuKitchenListLabels;
86
+ };
87
+
88
+ export type MenuFamilyBlockProps = {
89
+ family: MenuFamilyBlockPreview;
90
+ labels: MenuFamilyBlockLabels;
91
+ formatCount?: (value: number) => string;
92
+ /** Finished CSS colour for the chips. */
93
+ hue?: string;
94
+ /** Finished CSS gradient — the family's colour over the mosaic. */
95
+ gradient?: string;
96
+ linkComponent?: MenuLinkComponent;
97
+ className?: string;
98
+ };
99
+
100
+ /** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
101
+ const MOSAIC_TILES = 6;
102
+ const PHONE_TILES = 4;
103
+
104
+ /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
105
+ const GROUND = PHOTO_GROUND;
106
+
107
+ /**
108
+ * The frost: absent at the top, full at the foot on the phone; absent on the
109
+ * left, full on the right from `lg`. The mask is what makes it a gradient of
110
+ * softness rather than a frosted pane — the subject stays sharp.
111
+ */
112
+ const FROST =
113
+ "backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_0%,black_42%)] lg:[mask-image:linear-gradient(to_right,transparent_36%,black_54%)]";
114
+
115
+ /**
116
+ * The ink. Same direction as the frost, heavier at the foot, and it is the
117
+ * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
118
+ * lives in the app's stylesheet, not this package's, so a DS component reading
119
+ * it renders nothing in Storybook and the other three consumers.
120
+ */
121
+ const INK =
122
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.42)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.9)_48%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
123
+
124
+ /**
125
+ * A second, LOCAL scrim under the name, count and action — the photo column's
126
+ * own, rising from its foot.
127
+ *
128
+ * The global ink is deliberately light where the photography is the subject,
129
+ * and on a bright, warm-tinted picture (a bakery counter under the Romanian
130
+ * red) 30px of white serif sat at about 2.5:1 on it. Darkening the whole ink
131
+ * would have dulled every photograph to fix a corner of some; this darkens
132
+ * only the band the type actually occupies, and leaves the top of the picture
133
+ * clear. Same base colour as the ink, so the two read as one shadow.
134
+ */
135
+ const TEXT_SCRIM =
136
+ /* `isolate` + `before:-z-10`: a positioned pseudo-element paints ABOVE the
137
+ column's in-flow text unless the column is its own stacking context and
138
+ the layer is ordered beneath. Without `isolate`, `-z-10` would instead
139
+ drop it behind the card's mosaic — inside the article's context. */
140
+ "isolate before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:top-[30%] before:-z-10 before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0.55)_45%,oklch(0.16_0.012_55_/_0.8)_100%)] before:content-['']";
141
+
142
+ export function MenuFamilyBlock({
143
+ family,
144
+ labels,
145
+ formatCount = String,
146
+ hue,
147
+ gradient,
148
+ linkComponent: LinkComponent = DefaultLink,
149
+ className,
150
+ }: MenuFamilyBlockProps) {
151
+ const photos = (family.photoUrls ?? []).slice(0, MOSAIC_TILES);
152
+ const overflow = Math.max(family.venueCount - photos.length, 0);
153
+ const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
154
+ const venues = family.venues ?? [];
155
+
156
+ return (
157
+ <article
158
+ className={cn(
159
+ /* `isolate` confines the blend and the backdrop-filter to this card.
160
+ `dark` scopes every token inside to its dark-theme value. */
161
+ "dark relative isolate overflow-hidden rounded-[14px] text-foreground",
162
+ GROUND,
163
+ className
164
+ )}
165
+ >
166
+ {/* ── Layer 1: the photographs, wearing the family's colour ──────── */}
167
+ {/* A 2×2 on the phone, 3×2 from `lg`: at 1110px a four-tile grid makes
168
+ each photograph too big to read as texture. With no photographs at
169
+ all, one slot with the family's initial. */}
170
+ <PhotoGround
171
+ photos={photos.length > 0 ? photos : [undefined]}
172
+ initials={initial}
173
+ mosaicClassName={
174
+ photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
175
+ }
176
+ phoneTiles={PHONE_TILES}
177
+ gradient={gradient}
178
+ />
179
+
180
+ {/* ── Layer 2: the frost ──────────────────────────────────────────── */}
181
+ <span aria-hidden className={cn("absolute inset-0", FROST)} />
182
+
183
+ {/* ── Layer 3: the ink ────────────────────────────────────────────── */}
184
+ <span aria-hidden className={cn("absolute inset-0", INK)} />
185
+
186
+ {/* ── The content, floating ───────────────────────────────────────── */}
187
+ <div className="relative grid grid-cols-1 lg:grid-cols-[38%_1fr]">
188
+ {/* The photo column. On the phone it is the card's head; on desktop
189
+ the left third, with its stack anchored to the foot. */}
190
+ <div className={cn("relative flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5", TEXT_SCRIM)}>
191
+ {overflow > 0 && (
192
+ <span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-[2px] lg:mb-3 lg:self-start">
193
+ {labels.more(overflow)}
194
+ </span>
195
+ )}
196
+
197
+ <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
198
+ {family.name}
199
+ </h3>
200
+ <span className="mt-0.5 block text-[13px] font-medium tabular-nums text-white/70">
201
+ {labels.venueUnit(family.venueCount)}
202
+ </span>
203
+
204
+ {/* The action, as a frosted pill on the photography. `bg-white/12`
205
+ on the ink rather than `bg-accent`: ten filled accent buttons on
206
+ one page is ten primary actions, and the pill reads as part of the
207
+ picture rather than as a control laid on top of it.
208
+
209
+ No glyph. An arrow read as "leaves the site" beside a label that
210
+ already says where it goes, and a chevron would have made the pill
211
+ one more row: every venue row to its right ends in exactly that
212
+ chevron. The shape and the label are the whole affordance. */}
213
+ <LinkComponent
214
+ href={family.href}
215
+ className="mt-3.5 inline-flex h-10 w-max 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 hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none"
216
+ >
217
+ {labels.browse}
218
+ </LinkComponent>
219
+ </div>
220
+
221
+ {/* The frosted column: chips, then the rows. */}
222
+ <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
223
+ <ul className="flex flex-wrap items-center gap-1.5">
224
+ {family.priceBandLabel && (
225
+ /* No hue: a fact about money, not about the family. In the dark
226
+ scope `HueChip`'s `--muted` fallback is the dark pill the design
227
+ draws. */
228
+ <HueChip
229
+ as="li"
230
+ className="font-bold tabular-nums"
231
+ aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
232
+ icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
233
+ >
234
+ {family.priceBandLabel}
235
+ </HueChip>
236
+ )}
237
+ {family.chips?.map((chip) => (
238
+ /* Label only. The counts are VENUES beside a dish group's name,
239
+ which every reader takes for dishes. */
240
+ <HueChip key={chip.slug} as="li" hue={hue}>
241
+ {chip.label}
242
+ </HueChip>
243
+ ))}
244
+ </ul>
245
+
246
+ {venues.length > 0 && (
247
+ <>
248
+ <Separator className="my-3" />
249
+ <MenuKitchenList
250
+ kitchens={venues}
251
+ labels={labels.kitchens}
252
+ linkComponent={LinkComponent}
253
+ />
254
+ </>
255
+ )}
256
+ </div>
257
+ </div>
258
+ </article>
259
+ );
260
+ }
261
+
262
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * The block's own loading state, at the block's footprint.
266
+ *
267
+ * The ground is the card's own, so the placeholder is already the dark
268
+ * surface the photographs will land on — nothing flashes from paper to ink.
269
+ * `venues` matches the rows the caller will render, so a block showing three
270
+ * does not settle from a placeholder showing one.
271
+ */
272
+ export function MenuFamilyBlockSkeleton({
273
+ venues = 3,
274
+ className,
275
+ }: {
276
+ venues?: number;
277
+ className?: string;
278
+ }) {
279
+ return (
280
+ <div
281
+ className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
282
+ aria-hidden
283
+ >
284
+ <div className="grid grid-cols-1 lg:grid-cols-[38%_1fr]">
285
+ <div className="flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5">
286
+ <Skeleton className="mb-16 h-6 w-[72px] self-end rounded-full lg:mb-3 lg:self-start" />
287
+ <Skeleton className="h-7 w-[60%]" />
288
+ <Skeleton className="mt-2 h-3.5 w-[90px]" />
289
+ <Skeleton className="mt-3.5 h-10 w-[160px] rounded-full" />
290
+ </div>
291
+ <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
292
+ <div className="flex gap-1.5">
293
+ <Skeleton className="h-7 w-[86px] rounded-full" />
294
+ <Skeleton className="h-7 w-[74px] rounded-full" />
295
+ <Skeleton className="h-7 w-[66px] rounded-full" />
296
+ </div>
297
+ <Separator className="my-3" />
298
+ {Array.from({ length: venues }, (_, index) => (
299
+ <div key={index}>
300
+ {index > 0 && <Separator />}
301
+ <div className="flex items-center gap-3 py-2.5">
302
+ <Skeleton className="h-[46px] w-[46px] rounded-[11px]" />
303
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
304
+ <Skeleton className="h-3.5 w-[46%]" />
305
+ <Skeleton className="h-3 w-[58%]" />
306
+ </div>
307
+ </div>
308
+ </div>
309
+ ))}
310
+ </div>
311
+ </div>
312
+ </div>
313
+ );
314
+ }
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuFamilyCard } from "./menu-family-card";
3
+ import { mockDrinkFamily, mockFamily, mockGradients } from "./menu-mocks";
4
+
5
+ const labels = {
6
+ venueUnit: "kitchens",
7
+ priceBand: "kitchen price band",
8
+ browse: "Browse Italian",
9
+ };
10
+
11
+ /** A stand-in photograph with enough variance that the frost has something to soften. */
12
+ function photo(hue: number, label: string) {
13
+ return (
14
+ "data:image/svg+xml;utf8," +
15
+ encodeURIComponent(
16
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="320" height="240" fill="url(#g)"/><circle cx="80" cy="70" r="44" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="170" y="130" width="120" height="80" fill="hsl(${hue} 15% 20%)" opacity=".7"/><text x="50%" y="88%" font-family="monospace" font-size="16" fill="#fff" text-anchor="middle">${label}</text></svg>`
17
+ )
18
+ );
19
+ }
20
+
21
+ const PHOTOS = [40, 200, 90, 300].map((h, i) => photo(h, `venue ${i + 1}`));
22
+
23
+ const meta: Meta<typeof MenuFamilyCard> = {
24
+ title: "Menu/MenuFamilyCard",
25
+ component: MenuFamilyCard,
26
+ tags: ["autodocs"],
27
+ decorators: [
28
+ (Story) => (
29
+ <div className="w-[360px] bg-background p-4">
30
+ <Story />
31
+ </div>
32
+ ),
33
+ ],
34
+ parameters: {
35
+ docs: {
36
+ description: {
37
+ component:
38
+ "The hub's doorway to a family — a cuisine on the dishes tab, a drink family on the drinks tab. The same photo language as `MenuFamilyBlock` (the mosaic is the ground, wearing the family's hue; a masked frost and an ink gradient carry the type) drawn one step earlier, in a row of three, where the reader is choosing a family and not yet a venue: one number, one name, the groups the family consists of, and the `Browse` pill. It lists no venue. The count is VENUES: cuisine exists only on places, so a card counting dishes cannot be built. The whole card is one link.",
39
+ },
40
+ },
41
+ },
42
+ };
43
+
44
+ export default meta;
45
+ type Story = StoryObj<typeof MenuFamilyCard>;
46
+
47
+ export const Default: Story = {
48
+ args: {
49
+ family: { ...mockFamily, venue_photo_urls: PHOTOS },
50
+ labels,
51
+ href: "/menu/cuisines/italian",
52
+ hue: "oklch(0.55 0.15 40)",
53
+ gradient: mockGradients.pasta,
54
+ priceBandLabel: "32–84 lei",
55
+ },
56
+ };
57
+
58
+ /** The drinks tab. Same card, different unit and vocabulary. */
59
+ export const DrinkFamily: Story = {
60
+ args: {
61
+ family: { ...mockDrinkFamily, venue_photo_urls: PHOTOS },
62
+ labels: { venueUnit: "bars", priceBand: "bar price band", browse: "Browse cocktails" },
63
+ href: "/menu/drinks/cocktail",
64
+ hue: "oklch(0.5 0.13 240)",
65
+ gradient: mockGradients.cocktail,
66
+ priceBandLabel: "18–54 lei",
67
+ },
68
+ };
69
+
70
+ /** A one-venue cuisine: the count is honest, and the mosaic repeats one photo. */
71
+ export const Sparse: Story = {
72
+ args: {
73
+ family: {
74
+ slug: "portuguese",
75
+ name: "Portuguese",
76
+ venue_count: 1,
77
+ chips: [{ slug: "seafood-dish", label: "seafood" }],
78
+ venue_photo_urls: PHOTOS.slice(0, 1),
79
+ },
80
+ labels: { ...labels, browse: "Browse Portuguese" },
81
+ href: "/menu/cuisines/portuguese",
82
+ hue: "oklch(0.52 0.13 340)",
83
+ gradient: mockGradients.sweet,
84
+ priceBandLabel: "42–88 lei",
85
+ },
86
+ };
87
+
88
+ /** No photography at all: the ground with the family's initial, and no tint. */
89
+ export const NoPhotos: Story = {
90
+ args: {
91
+ ...Default.args,
92
+ family: { ...mockFamily, venue_photo_urls: [] },
93
+ },
94
+ };
95
+
96
+ /** The row of three the card is designed to be scanned in. */
97
+ export const Row: Story = {
98
+ render: () => (
99
+ <div className="grid grid-cols-3 gap-5">
100
+ {[mockFamily, mockDrinkFamily, mockFamily].map((family, index) => (
101
+ <MenuFamilyCard
102
+ key={index}
103
+ family={{ ...family, venue_photo_urls: PHOTOS }}
104
+ labels={labels}
105
+ href={`/menu/cuisines/${family.slug}`}
106
+ hue="oklch(0.55 0.15 40)"
107
+ gradient={Object.values(mockGradients)[index]}
108
+ priceBandLabel="32–84 lei"
109
+ />
110
+ ))}
111
+ </div>
112
+ ),
113
+ decorators: [
114
+ (Story) => (
115
+ <div className="w-[1100px] bg-background p-4">
116
+ <Story />
117
+ </div>
118
+ ),
119
+ ],
120
+ };