@bearmenu/ui 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  5. package/dist/chunk-CLLY6XB3.js +41 -0
  6. package/dist/chunk-DTXNDLSE.js +125 -0
  7. package/dist/chunk-F6FMZJDG.js +113 -0
  8. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  9. package/dist/chunk-MZL7CGCL.js +109 -0
  10. package/dist/chunk-SY6RV476.js +48 -0
  11. package/dist/chunk-UDN7UHJW.js +60 -0
  12. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/command.d.ts +7 -7
  16. package/dist/components/concept-compare-rail.d.ts +50 -0
  17. package/dist/components/concept-compare-rail.js +5 -0
  18. package/dist/components/concept-compare.d.ts +75 -38
  19. package/dist/components/concept-compare.js +209 -133
  20. package/dist/components/concept-convergence.d.ts +41 -0
  21. package/dist/components/concept-convergence.js +53 -0
  22. package/dist/components/convergence.d.ts +25 -0
  23. package/dist/components/convergence.js +4 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-index-row.js +1 -1
  29. package/dist/components/event-list-row-wide.js +1 -1
  30. package/dist/components/event-list-row.js +2 -2
  31. package/dist/components/explainer-steps.d.ts +34 -0
  32. package/dist/components/explainer-steps.js +36 -0
  33. package/dist/components/glass-ground.d.ts +66 -0
  34. package/dist/components/glass-ground.js +3 -0
  35. package/dist/components/hub-surface.js +11 -1
  36. package/dist/components/hue-chip.d.ts +70 -0
  37. package/dist/components/hue-chip.js +3 -0
  38. package/dist/components/kitchen-dossier.d.ts +88 -0
  39. package/dist/components/kitchen-dossier.js +187 -0
  40. package/dist/components/menu-dead-end.d.ts +56 -0
  41. package/dist/components/menu-dead-end.js +49 -0
  42. package/dist/components/menu-family-block.d.ts +99 -0
  43. package/dist/components/menu-family-block.js +155 -0
  44. package/dist/components/menu-family-card.d.ts +33 -27
  45. package/dist/components/menu-family-card.js +43 -85
  46. package/dist/components/menu-field.js +92 -2
  47. package/dist/components/menu-filter-chips.d.ts +86 -0
  48. package/dist/components/menu-filter-chips.js +122 -0
  49. package/dist/components/menu-group-rows.d.ts +53 -0
  50. package/dist/components/menu-group-rows.js +67 -0
  51. package/dist/components/menu-index-header.d.ts +37 -0
  52. package/dist/components/menu-index-header.js +68 -0
  53. package/dist/components/menu-index-list.d.ts +48 -0
  54. package/dist/components/menu-index-list.js +96 -0
  55. package/dist/components/menu-intent-hero.d.ts +69 -0
  56. package/dist/components/menu-intent-hero.js +87 -0
  57. package/dist/components/menu-kitchen-list.d.ts +82 -0
  58. package/dist/components/menu-kitchen-list.js +5 -0
  59. package/dist/components/menu-mocks.d.ts +37 -2
  60. package/dist/components/menu-mocks.js +152 -27
  61. package/dist/components/menu-types.d.ts +182 -6
  62. package/dist/components/motion-icon-controls.js +2 -2
  63. package/dist/components/motion-icon-set.js +6 -6
  64. package/dist/components/motion-icon.d.ts +22 -1
  65. package/dist/components/motion-icon.js +1 -1
  66. package/dist/components/photo-ground.d.ts +64 -0
  67. package/dist/components/photo-ground.js +3 -0
  68. package/dist/components/place-about-tab.js +2 -2
  69. package/dist/components/place-details-mobile.js +2 -2
  70. package/dist/components/place-types.d.ts +2 -2
  71. package/dist/components/qa-block.d.ts +36 -0
  72. package/dist/components/qa-block.js +24 -0
  73. package/dist/components/scroll-fade.js +3 -121
  74. package/dist/components/snap-rail.d.ts +24 -1
  75. package/dist/components/snap-rail.js +3 -109
  76. package/dist/components/spinner.d.ts +2 -2
  77. package/dist/components/story.d.ts +34 -0
  78. package/dist/components/story.js +3 -0
  79. package/dist/components/text.d.ts +3 -3
  80. package/dist/components/top-comparison.d.ts +28 -0
  81. package/dist/components/top-comparison.js +76 -0
  82. package/dist/components/top-cross-link.d.ts +34 -0
  83. package/dist/components/top-cross-link.js +101 -0
  84. package/dist/components/top-ranking-entry.d.ts +53 -0
  85. package/dist/components/top-ranking-entry.js +217 -0
  86. package/dist/components/top-ranking-hero.d.ts +61 -0
  87. package/dist/components/top-ranking-hero.js +99 -0
  88. package/dist/components/top-ranking-legend.d.ts +69 -0
  89. package/dist/components/top-ranking-legend.js +124 -0
  90. package/dist/components/top-types.d.ts +109 -0
  91. package/dist/components/top-types.js +1 -0
  92. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  93. package/dist/components/venue-grade-dimensions.js +1 -1
  94. package/dist/components/weekend-panel.js +2 -2
  95. package/package.json +1 -1
  96. package/src/components/concept-compare-rail.tsx +131 -0
  97. package/src/components/concept-compare.stories.tsx +122 -54
  98. package/src/components/concept-compare.tsx +363 -196
  99. package/src/components/concept-convergence.stories.tsx +53 -0
  100. package/src/components/concept-convergence.tsx +90 -0
  101. package/src/components/convergence.stories.tsx +54 -0
  102. package/src/components/convergence.tsx +177 -0
  103. package/src/components/explainer-steps.stories.tsx +46 -0
  104. package/src/components/explainer-steps.tsx +74 -0
  105. package/src/components/glass-ground.tsx +129 -0
  106. package/src/components/hub-surface.tsx +11 -1
  107. package/src/components/hue-chip.stories.tsx +76 -0
  108. package/src/components/hue-chip.test.tsx +62 -0
  109. package/src/components/hue-chip.tsx +99 -0
  110. package/src/components/kitchen-dossier.stories.tsx +108 -0
  111. package/src/components/kitchen-dossier.tsx +322 -0
  112. package/src/components/menu-dead-end.stories.tsx +67 -0
  113. package/src/components/menu-dead-end.tsx +127 -0
  114. package/src/components/menu-family-block.stories.tsx +91 -0
  115. package/src/components/menu-family-block.tsx +314 -0
  116. package/src/components/menu-family-card.stories.tsx +43 -35
  117. package/src/components/menu-family-card.tsx +122 -142
  118. package/src/components/menu-filter-chips.stories.tsx +107 -0
  119. package/src/components/menu-filter-chips.tsx +238 -0
  120. package/src/components/menu-group-rows.stories.tsx +54 -0
  121. package/src/components/menu-group-rows.tsx +133 -0
  122. package/src/components/menu-index-header.stories.tsx +72 -0
  123. package/src/components/menu-index-header.tsx +125 -0
  124. package/src/components/menu-index-list.stories.tsx +50 -0
  125. package/src/components/menu-index-list.tsx +149 -0
  126. package/src/components/menu-intent-hero.stories.tsx +67 -0
  127. package/src/components/menu-intent-hero.tsx +189 -0
  128. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  129. package/src/components/menu-kitchen-list.test.tsx +78 -0
  130. package/src/components/menu-kitchen-list.tsx +284 -0
  131. package/src/components/menu-mocks.ts +198 -23
  132. package/src/components/menu-types.ts +189 -6
  133. package/src/components/motion-icon-controls.tsx +11 -11
  134. package/src/components/motion-icon-set.tsx +6 -5
  135. package/src/components/motion-icon.test.tsx +23 -1
  136. package/src/components/motion-icon.tsx +20 -0
  137. package/src/components/photo-ground.tsx +147 -0
  138. package/src/components/place-types.ts +2 -2
  139. package/src/components/qa-block.stories.tsx +43 -0
  140. package/src/components/qa-block.tsx +60 -0
  141. package/src/components/snap-rail.tsx +58 -15
  142. package/src/components/story.tsx +225 -0
  143. package/src/components/top-comparison.stories.tsx +78 -0
  144. package/src/components/top-comparison.tsx +151 -0
  145. package/src/components/top-cross-link.stories.tsx +84 -0
  146. package/src/components/top-cross-link.tsx +171 -0
  147. package/src/components/top-ranking-entry.stories.tsx +171 -0
  148. package/src/components/top-ranking-entry.test.tsx +105 -0
  149. package/src/components/top-ranking-entry.tsx +425 -0
  150. package/src/components/top-ranking-hero.stories.tsx +76 -0
  151. package/src/components/top-ranking-hero.tsx +218 -0
  152. package/src/components/top-ranking-legend.stories.tsx +78 -0
  153. package/src/components/top-ranking-legend.tsx +239 -0
  154. package/src/components/top-types.ts +110 -0
  155. package/src/components/venue-grade-dimensions.tsx +2 -2
  156. package/dist/chunk-THOVPZNN.js +0 -37
  157. package/dist/chunk-YS6BHKZO.js +0 -92
  158. package/dist/components/ranked-venue-list.d.ts +0 -49
  159. package/dist/components/ranked-venue-list.js +0 -87
  160. package/dist/components/signature-line-list.d.ts +0 -45
  161. package/dist/components/signature-line-list.js +0 -99
  162. package/dist/components/venue-busyness-strip.d.ts +0 -35
  163. package/dist/components/venue-busyness-strip.js +0 -37
  164. package/dist/components/venue-signature-block.d.ts +0 -39
  165. package/dist/components/venue-signature-block.js +0 -6
  166. package/dist/components/venue-spotlight.d.ts +0 -74
  167. package/dist/components/venue-spotlight.js +0 -138
  168. package/src/components/ranked-venue-list.stories.tsx +0 -80
  169. package/src/components/ranked-venue-list.tsx +0 -177
  170. package/src/components/signature-line-list.stories.tsx +0 -85
  171. package/src/components/signature-line-list.tsx +0 -136
  172. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  173. package/src/components/venue-busyness-strip.tsx +0 -64
  174. package/src/components/venue-signature-block.stories.tsx +0 -68
  175. package/src/components/venue-signature-block.tsx +0 -71
  176. package/src/components/venue-spotlight.stories.tsx +0 -141
  177. package/src/components/venue-spotlight.tsx +0 -236
  178. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -1,28 +1,32 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { MenuFamilyCard } from "./menu-family-card";
3
- import { mockDrinkFamily, mockFamily } from "./menu-mocks";
4
-
5
- const itemLabels = {
6
- currency: "lei",
7
- mentions: (count: number) => `${count} mentions`,
8
- signature: "SIGNATURE",
9
- mentioned: "MENTIONED",
10
- };
11
- const formatPrice = (amount: number) => String(amount);
3
+ import { mockDrinkFamily, mockFamily, mockGradients } from "./menu-mocks";
12
4
 
13
5
  const labels = {
14
- venueUnit: "KITCHENS",
15
- mostNamed: "Most named",
6
+ venueUnit: "kitchens",
16
7
  priceBand: "kitchen price band",
8
+ browse: "Browse Italian",
17
9
  };
18
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
+
19
23
  const meta: Meta<typeof MenuFamilyCard> = {
20
24
  title: "Menu/MenuFamilyCard",
21
25
  component: MenuFamilyCard,
22
26
  tags: ["autodocs"],
23
27
  decorators: [
24
28
  (Story) => (
25
- <div className="w-[300px] bg-background p-4">
29
+ <div className="w-[360px] bg-background p-4">
26
30
  <Story />
27
31
  </div>
28
32
  ),
@@ -31,7 +35,7 @@ const meta: Meta<typeof MenuFamilyCard> = {
31
35
  docs: {
32
36
  description: {
33
37
  component:
34
- "A browsable grouping of venues — a cuisine on the dishes tab, a drink family on the drinks tab. Two halves with two jobs: the dark panel is for scanning a row of four (one number, one name, the groups the family consists of, over a mosaic of its venues), and the white half underneath answers what you would actually order, as the production `MenuItem`. Both halves count VENUES: cuisine exists only on places, so a card counting dishes cannot be built.",
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.",
35
39
  },
36
40
  },
37
41
  },
@@ -42,12 +46,11 @@ type Story = StoryObj<typeof MenuFamilyCard>;
42
46
 
43
47
  export const Default: Story = {
44
48
  args: {
45
- family: mockFamily,
49
+ family: { ...mockFamily, venue_photo_urls: PHOTOS },
46
50
  labels,
47
- itemLabels,
48
- formatPrice,
49
- href: "/top/italian",
51
+ href: "/menu/cuisines/italian",
50
52
  hue: "oklch(0.55 0.15 40)",
53
+ gradient: mockGradients.pasta,
51
54
  priceBandLabel: "32–84 lei",
52
55
  },
53
56
  };
@@ -55,12 +58,11 @@ export const Default: Story = {
55
58
  /** The drinks tab. Same card, different unit and vocabulary. */
56
59
  export const DrinkFamily: Story = {
57
60
  args: {
58
- family: mockDrinkFamily,
59
- labels: { venueUnit: "BARS", mostNamed: "Most named", priceBand: "bar price band" },
60
- itemLabels,
61
- formatPrice,
62
- href: "/top/baruri",
61
+ family: { ...mockDrinkFamily, venue_photo_urls: PHOTOS },
62
+ labels: { venueUnit: "bars", priceBand: "bar price band", browse: "Browse cocktails" },
63
+ href: "/menu/drinks/cocktail",
63
64
  hue: "oklch(0.5 0.13 240)",
65
+ gradient: mockGradients.cocktail,
64
66
  priceBandLabel: "18–54 lei",
65
67
  },
66
68
  };
@@ -73,30 +75,36 @@ export const Sparse: Story = {
73
75
  name: "Portuguese",
74
76
  venue_count: 1,
75
77
  chips: [{ slug: "seafood-dish", label: "seafood" }],
76
- signature: mockFamily.signature,
78
+ venue_photo_urls: PHOTOS.slice(0, 1),
77
79
  },
78
- labels,
79
- itemLabels,
80
- formatPrice,
81
- href: "/top/restaurante",
80
+ labels: { ...labels, browse: "Browse Portuguese" },
81
+ href: "/menu/cuisines/portuguese",
82
82
  hue: "oklch(0.52 0.13 340)",
83
+ gradient: mockGradients.sweet,
83
84
  priceBandLabel: "42–88 lei",
84
85
  },
85
86
  };
86
87
 
87
- /** The row of four the card is designed to be scanned in. */
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. */
88
97
  export const Row: Story = {
89
98
  render: () => (
90
- <div className="grid grid-cols-4 gap-5">
91
- {[mockFamily, mockDrinkFamily, mockFamily, mockDrinkFamily].map((family, index) => (
99
+ <div className="grid grid-cols-3 gap-5">
100
+ {[mockFamily, mockDrinkFamily, mockFamily].map((family, index) => (
92
101
  <MenuFamilyCard
93
102
  key={index}
94
- family={family}
103
+ family={{ ...family, venue_photo_urls: PHOTOS }}
95
104
  labels={labels}
96
- itemLabels={itemLabels}
97
- formatPrice={formatPrice}
98
- href={`/top/${family.slug}`}
105
+ href={`/menu/cuisines/${family.slug}`}
99
106
  hue="oklch(0.55 0.15 40)"
107
+ gradient={Object.values(mockGradients)[index]}
100
108
  priceBandLabel="32–84 lei"
101
109
  />
102
110
  ))}
@@ -104,7 +112,7 @@ export const Row: Story = {
104
112
  ),
105
113
  decorators: [
106
114
  (Story) => (
107
- <div className="w-[1200px] bg-background p-4">
115
+ <div className="w-[1100px] bg-background p-4">
108
116
  <Story />
109
117
  </div>
110
118
  ),
@@ -1,32 +1,38 @@
1
1
  import { Wallet } from "lucide-react";
2
2
  import { cn } from "../lib/utils";
3
- import { MenuItem, type MenuItemLabels } from "./menu-item";
3
+ import { HueChip } from "./hue-chip";
4
4
  import type { MenuFamilyPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
5
+ import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
5
6
 
6
7
  /**
7
8
  * MenuFamilyCard — a browsable grouping of venues: a cuisine on the dishes tab,
8
- * a drink family on the drinks tab.
9
+ * a drink family on the drinks tab. The hub's doorway to a family.
9
10
  *
10
- * ## The count does the scanning, the item answers the question
11
+ * ## A doorway, not a summary
11
12
  *
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 orderthe production `<MenuItem>`, with its price, portion and
16
- * mention count, so the recommendation is the review census rather than an
17
- * editorial pick.
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.
18
20
  *
19
- * ## Both halves count VENUES
21
+ * ## The count does the scanning
20
22
  *
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.
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.
26
30
  *
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.
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.
30
36
  */
31
37
 
32
38
  /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
@@ -39,22 +45,24 @@ function DefaultLink({ href, ...props }: MenuFamilyCardLinkProps) {
39
45
  export type MenuFamilyCardLabels = {
40
46
  /** Unit under the count, e.g. "KITCHENS". */
41
47
  venueUnit: string;
42
- /** Kicker over the featured item, e.g. "MOST NAMED". */
43
- mostNamed: string;
44
- /** Footer caption, e.g. "kitchen price band". */
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
+ */
45
53
  priceBand: string;
54
+ /** The action pill, e.g. "Browse Italian". Sits on the photography — keep it short. */
55
+ browse: string;
46
56
  };
47
57
 
48
58
  export type MenuFamilyCardProps = {
49
59
  family: MenuFamilyPreview;
50
60
  labels: MenuFamilyCardLabels;
51
- /** Passed through to the featured `<MenuItem>`. */
52
- itemLabels: MenuItemLabels;
53
- formatPrice: (amount: number) => string;
54
- formatCount?: (value: number) => string;
55
61
  href: string;
56
- /** Finished CSS colour for the featured item's category mark. */
62
+ /** Finished CSS colour for the chips. */
57
63
  hue?: string;
64
+ /** Finished CSS gradient — the family's colour over the mosaic. */
65
+ gradient?: string;
58
66
  /** Composed price band, e.g. "32–84 lei". */
59
67
  priceBandLabel?: string;
60
68
  linkComponent?: MenuLinkComponent;
@@ -65,147 +73,119 @@ export type MenuFamilyCardProps = {
65
73
  * A 2×2, not a 2×4.
66
74
  *
67
75
  * 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.
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.
72
80
  */
73
81
  const MOSAIC_CELLS = 4;
74
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
+
75
103
  export function MenuFamilyCard({
76
104
  family,
77
105
  labels,
78
- itemLabels,
79
- formatPrice,
80
- formatCount,
81
106
  href,
82
107
  hue,
108
+ gradient,
83
109
  priceBandLabel,
84
110
  linkComponent: LinkComponent = DefaultLink,
85
111
  className,
86
112
  }: MenuFamilyCardProps) {
87
113
  const photos = family.venue_photo_urls ?? [];
88
- const groups = (family.chips ?? []).map((chip) => chip.label);
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];
89
120
 
90
121
  return (
91
- <div
122
+ <LinkComponent
123
+ href={href}
92
124
  className={cn(
93
- "flex h-full flex-col overflow-hidden rounded-[14px] border border-border bg-card",
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,
94
128
  className
95
129
  )}
96
130
  >
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
- )}
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}
120
153
  </span>
154
+ </span>
121
155
 
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
- )}
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>
172
160
 
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. */}
161
+ {(priceBandLabel || (family.chips?.length ?? 0) > 0) && (
162
+ <span className="mt-2.5 flex flex-wrap items-center gap-1.5">
176
163
  {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>
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>
182
173
  )}
174
+ {family.chips?.map((chip) => (
175
+ <HueChip key={chip.slug} hue={hue}>
176
+ {chip.label}
177
+ </HueChip>
178
+ ))}
183
179
  </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>
180
+ )}
192
181
 
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>
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>
210
190
  );
211
191
  }
@@ -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
+ };