@bearmenu/ui 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  5. package/dist/chunk-CLLY6XB3.js +41 -0
  6. package/dist/chunk-DTXNDLSE.js +125 -0
  7. package/dist/chunk-F6FMZJDG.js +113 -0
  8. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  9. package/dist/chunk-MZL7CGCL.js +109 -0
  10. package/dist/chunk-SY6RV476.js +48 -0
  11. package/dist/chunk-UDN7UHJW.js +60 -0
  12. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/command.d.ts +7 -7
  16. package/dist/components/concept-compare-rail.d.ts +50 -0
  17. package/dist/components/concept-compare-rail.js +5 -0
  18. package/dist/components/concept-compare.d.ts +75 -38
  19. package/dist/components/concept-compare.js +209 -133
  20. package/dist/components/concept-convergence.d.ts +41 -0
  21. package/dist/components/concept-convergence.js +53 -0
  22. package/dist/components/convergence.d.ts +25 -0
  23. package/dist/components/convergence.js +4 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-index-row.js +1 -1
  29. package/dist/components/event-list-row-wide.js +1 -1
  30. package/dist/components/event-list-row.js +2 -2
  31. package/dist/components/explainer-steps.d.ts +34 -0
  32. package/dist/components/explainer-steps.js +36 -0
  33. package/dist/components/glass-ground.d.ts +66 -0
  34. package/dist/components/glass-ground.js +3 -0
  35. package/dist/components/hub-surface.js +11 -1
  36. package/dist/components/hue-chip.d.ts +70 -0
  37. package/dist/components/hue-chip.js +3 -0
  38. package/dist/components/kitchen-dossier.d.ts +88 -0
  39. package/dist/components/kitchen-dossier.js +187 -0
  40. package/dist/components/menu-dead-end.d.ts +56 -0
  41. package/dist/components/menu-dead-end.js +49 -0
  42. package/dist/components/menu-family-block.d.ts +99 -0
  43. package/dist/components/menu-family-block.js +155 -0
  44. package/dist/components/menu-family-card.d.ts +33 -27
  45. package/dist/components/menu-family-card.js +43 -85
  46. package/dist/components/menu-field.js +92 -2
  47. package/dist/components/menu-filter-chips.d.ts +86 -0
  48. package/dist/components/menu-filter-chips.js +122 -0
  49. package/dist/components/menu-group-rows.d.ts +53 -0
  50. package/dist/components/menu-group-rows.js +67 -0
  51. package/dist/components/menu-index-header.d.ts +37 -0
  52. package/dist/components/menu-index-header.js +68 -0
  53. package/dist/components/menu-index-list.d.ts +48 -0
  54. package/dist/components/menu-index-list.js +96 -0
  55. package/dist/components/menu-intent-hero.d.ts +69 -0
  56. package/dist/components/menu-intent-hero.js +87 -0
  57. package/dist/components/menu-kitchen-list.d.ts +82 -0
  58. package/dist/components/menu-kitchen-list.js +5 -0
  59. package/dist/components/menu-mocks.d.ts +37 -2
  60. package/dist/components/menu-mocks.js +152 -27
  61. package/dist/components/menu-types.d.ts +182 -6
  62. package/dist/components/motion-icon-controls.js +2 -2
  63. package/dist/components/motion-icon-set.js +6 -6
  64. package/dist/components/motion-icon.d.ts +22 -1
  65. package/dist/components/motion-icon.js +1 -1
  66. package/dist/components/photo-ground.d.ts +64 -0
  67. package/dist/components/photo-ground.js +3 -0
  68. package/dist/components/place-about-tab.js +2 -2
  69. package/dist/components/place-details-mobile.js +2 -2
  70. package/dist/components/place-types.d.ts +2 -2
  71. package/dist/components/qa-block.d.ts +36 -0
  72. package/dist/components/qa-block.js +24 -0
  73. package/dist/components/scroll-fade.js +3 -121
  74. package/dist/components/snap-rail.d.ts +24 -1
  75. package/dist/components/snap-rail.js +3 -109
  76. package/dist/components/spinner.d.ts +2 -2
  77. package/dist/components/story.d.ts +34 -0
  78. package/dist/components/story.js +3 -0
  79. package/dist/components/text.d.ts +3 -3
  80. package/dist/components/top-comparison.d.ts +28 -0
  81. package/dist/components/top-comparison.js +76 -0
  82. package/dist/components/top-cross-link.d.ts +34 -0
  83. package/dist/components/top-cross-link.js +101 -0
  84. package/dist/components/top-ranking-entry.d.ts +53 -0
  85. package/dist/components/top-ranking-entry.js +217 -0
  86. package/dist/components/top-ranking-hero.d.ts +61 -0
  87. package/dist/components/top-ranking-hero.js +99 -0
  88. package/dist/components/top-ranking-legend.d.ts +69 -0
  89. package/dist/components/top-ranking-legend.js +124 -0
  90. package/dist/components/top-types.d.ts +109 -0
  91. package/dist/components/top-types.js +1 -0
  92. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  93. package/dist/components/venue-grade-dimensions.js +1 -1
  94. package/dist/components/weekend-panel.js +2 -2
  95. package/package.json +1 -1
  96. package/src/components/concept-compare-rail.tsx +131 -0
  97. package/src/components/concept-compare.stories.tsx +122 -54
  98. package/src/components/concept-compare.tsx +363 -196
  99. package/src/components/concept-convergence.stories.tsx +53 -0
  100. package/src/components/concept-convergence.tsx +90 -0
  101. package/src/components/convergence.stories.tsx +54 -0
  102. package/src/components/convergence.tsx +177 -0
  103. package/src/components/explainer-steps.stories.tsx +46 -0
  104. package/src/components/explainer-steps.tsx +74 -0
  105. package/src/components/glass-ground.tsx +129 -0
  106. package/src/components/hub-surface.tsx +11 -1
  107. package/src/components/hue-chip.stories.tsx +76 -0
  108. package/src/components/hue-chip.test.tsx +62 -0
  109. package/src/components/hue-chip.tsx +99 -0
  110. package/src/components/kitchen-dossier.stories.tsx +108 -0
  111. package/src/components/kitchen-dossier.tsx +322 -0
  112. package/src/components/menu-dead-end.stories.tsx +67 -0
  113. package/src/components/menu-dead-end.tsx +127 -0
  114. package/src/components/menu-family-block.stories.tsx +91 -0
  115. package/src/components/menu-family-block.tsx +314 -0
  116. package/src/components/menu-family-card.stories.tsx +43 -35
  117. package/src/components/menu-family-card.tsx +122 -142
  118. package/src/components/menu-filter-chips.stories.tsx +107 -0
  119. package/src/components/menu-filter-chips.tsx +238 -0
  120. package/src/components/menu-group-rows.stories.tsx +54 -0
  121. package/src/components/menu-group-rows.tsx +133 -0
  122. package/src/components/menu-index-header.stories.tsx +72 -0
  123. package/src/components/menu-index-header.tsx +125 -0
  124. package/src/components/menu-index-list.stories.tsx +50 -0
  125. package/src/components/menu-index-list.tsx +149 -0
  126. package/src/components/menu-intent-hero.stories.tsx +67 -0
  127. package/src/components/menu-intent-hero.tsx +189 -0
  128. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  129. package/src/components/menu-kitchen-list.test.tsx +78 -0
  130. package/src/components/menu-kitchen-list.tsx +284 -0
  131. package/src/components/menu-mocks.ts +198 -23
  132. package/src/components/menu-types.ts +189 -6
  133. package/src/components/motion-icon-controls.tsx +11 -11
  134. package/src/components/motion-icon-set.tsx +6 -5
  135. package/src/components/motion-icon.test.tsx +23 -1
  136. package/src/components/motion-icon.tsx +20 -0
  137. package/src/components/photo-ground.tsx +147 -0
  138. package/src/components/place-types.ts +2 -2
  139. package/src/components/qa-block.stories.tsx +43 -0
  140. package/src/components/qa-block.tsx +60 -0
  141. package/src/components/snap-rail.tsx +58 -15
  142. package/src/components/story.tsx +225 -0
  143. package/src/components/top-comparison.stories.tsx +78 -0
  144. package/src/components/top-comparison.tsx +151 -0
  145. package/src/components/top-cross-link.stories.tsx +84 -0
  146. package/src/components/top-cross-link.tsx +171 -0
  147. package/src/components/top-ranking-entry.stories.tsx +171 -0
  148. package/src/components/top-ranking-entry.test.tsx +105 -0
  149. package/src/components/top-ranking-entry.tsx +425 -0
  150. package/src/components/top-ranking-hero.stories.tsx +76 -0
  151. package/src/components/top-ranking-hero.tsx +218 -0
  152. package/src/components/top-ranking-legend.stories.tsx +78 -0
  153. package/src/components/top-ranking-legend.tsx +239 -0
  154. package/src/components/top-types.ts +110 -0
  155. package/src/components/venue-grade-dimensions.tsx +2 -2
  156. package/dist/chunk-THOVPZNN.js +0 -37
  157. package/dist/chunk-YS6BHKZO.js +0 -92
  158. package/dist/components/ranked-venue-list.d.ts +0 -49
  159. package/dist/components/ranked-venue-list.js +0 -87
  160. package/dist/components/signature-line-list.d.ts +0 -45
  161. package/dist/components/signature-line-list.js +0 -99
  162. package/dist/components/venue-busyness-strip.d.ts +0 -35
  163. package/dist/components/venue-busyness-strip.js +0 -37
  164. package/dist/components/venue-signature-block.d.ts +0 -39
  165. package/dist/components/venue-signature-block.js +0 -6
  166. package/dist/components/venue-spotlight.d.ts +0 -74
  167. package/dist/components/venue-spotlight.js +0 -138
  168. package/src/components/ranked-venue-list.stories.tsx +0 -80
  169. package/src/components/ranked-venue-list.tsx +0 -177
  170. package/src/components/signature-line-list.stories.tsx +0 -85
  171. package/src/components/signature-line-list.tsx +0 -136
  172. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  173. package/src/components/venue-busyness-strip.tsx +0 -64
  174. package/src/components/venue-signature-block.stories.tsx +0 -68
  175. package/src/components/venue-signature-block.tsx +0 -71
  176. package/src/components/venue-spotlight.stories.tsx +0 -141
  177. package/src/components/venue-spotlight.tsx +0 -236
  178. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuDeadEnd, MenuLoosenList } from "./menu-dead-end";
3
+ import { mockLoosenOptions } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof MenuDeadEnd> = {
6
+ title: "Menu/MenuDeadEnd",
7
+ component: MenuDeadEnd,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[900px] bg-background p-4">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "A filter combination that returns nothing is the intent page's worst state, and an empty-state illustration is the worst answer to it: the reader already knows the list is empty — what they do not know is which of their four filters did it. So this names what dropped out, why, and what one specific widening would restore, with the count. It is also drawn on a non-empty list whenever a filter has cost the reader something notable.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof MenuDeadEnd>;
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ children: (
32
+ <>
33
+ <b>Salt &amp; Stone</b> and six others dropped out — their cheapest vegetarian pasta is 58
34
+ lei. Raising the ceiling to 60 brings back <b>five kitchens</b>, including the highest-rated
35
+ Italian room in the city.
36
+ </>
37
+ ),
38
+ actionLabel: "Raise to 60 lei · 16 kitchens",
39
+ onAction: () => {},
40
+ },
41
+ };
42
+
43
+ /** No single best widening — the panel explains, the list below prices each one. */
44
+ export const WithoutAction: Story = {
45
+ args: {
46
+ children: (
47
+ <>
48
+ Nothing matches all four filters. <b>Open now</b> is the most expensive of them.
49
+ </>
50
+ ),
51
+ },
52
+ };
53
+
54
+ export const LoosenList: StoryObj<typeof MenuLoosenList> = {
55
+ render: () => <MenuLoosenList options={mockLoosenOptions} />,
56
+ };
57
+
58
+ export const Mobile: Story = {
59
+ args: Default.args,
60
+ decorators: [
61
+ (Story) => (
62
+ <div className="w-[390px] bg-background p-3">
63
+ <Story />
64
+ </div>
65
+ ),
66
+ ],
67
+ };
@@ -0,0 +1,127 @@
1
+ import { Info } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { MenuLinkComponent, MenuLinkProps, MenuLoosenOption } from "./menu-types";
4
+
5
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
6
+ return <a href={href} {...props} />;
7
+ }
8
+
9
+ /**
10
+ * MenuDeadEnd — what dropped out, and the one press that brings it back.
11
+ *
12
+ * A filter combination that returns nothing is the intent page's worst state,
13
+ * and an empty-state illustration is the worst answer to it: the reader knows
14
+ * the list is empty, what they do not know is WHICH of their four filters did
15
+ * it. So this says what dropped out, why, and what widening one specific filter
16
+ * would restore — with the count, because "+5 kitchens, including the
17
+ * highest-rated Italian room in the city" is an answer and "try removing a
18
+ * filter" is a shrug.
19
+ *
20
+ * It is also drawn on a NON-empty list, whenever a filter has cost the reader
21
+ * something notable. The board's case is 34 kitchens still standing and Casa
22
+ * Boema — the one they came for — missing.
23
+ *
24
+ * Interactive: the consumer marks its own module `"use client"`.
25
+ */
26
+
27
+ export type MenuDeadEndProps = {
28
+ /**
29
+ * The explanation, composed by the caller.
30
+ *
31
+ * A node rather than a string because it emphasises real names ("**Casa
32
+ * Boema** and six others dropped out"), and assembling that from fragments
33
+ * here would produce translated word-salad in one of the two locales.
34
+ */
35
+ children: React.ReactNode;
36
+ /** The widening this offers, e.g. "Raise to 60 lei · 16 kitchens". */
37
+ actionLabel?: string;
38
+ /** Where the widening leads. Preferred over `onAction` — see `MenuLoosenOption.href`. */
39
+ actionHref?: string;
40
+ onAction?: () => void;
41
+ linkComponent?: MenuLinkComponent;
42
+ className?: string;
43
+ };
44
+
45
+ export function MenuDeadEnd({
46
+ children,
47
+ actionLabel,
48
+ actionHref,
49
+ onAction,
50
+ linkComponent: LinkComponent = DefaultLink,
51
+ className,
52
+ }: MenuDeadEndProps) {
53
+ const actionClass =
54
+ "inline-flex h-7 shrink-0 items-center rounded-full bg-foreground px-3 text-[12.5px] font-bold text-background transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none lg:ml-auto";
55
+
56
+ return (
57
+ <div
58
+ className={cn(
59
+ "mt-4 flex flex-wrap items-center gap-3 rounded-[14px] bg-muted p-3.5 lg:flex-nowrap lg:gap-4 lg:px-[18px] lg:py-4",
60
+ className
61
+ )}
62
+ >
63
+ <Info className="h-[18px] w-[18px] shrink-0 text-muted-foreground" aria-hidden />
64
+ <p className="max-w-[560px] text-[13px] leading-[1.55] text-muted-foreground [&_b]:font-bold [&_b]:text-foreground">
65
+ {children}
66
+ </p>
67
+ {actionLabel &&
68
+ (actionHref ? (
69
+ <LinkComponent href={actionHref} className={actionClass}>
70
+ {actionLabel}
71
+ </LinkComponent>
72
+ ) : (
73
+ onAction && (
74
+ <button type="button" onClick={onAction} className={actionClass}>
75
+ {actionLabel}
76
+ </button>
77
+ )
78
+ ))}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /**
84
+ * MenuLoosenList — one row per active filter, each priced in kitchens.
85
+ *
86
+ * The companion to the panel above: the panel names the single best widening,
87
+ * this lists every one of them so the reader can choose which constraint they
88
+ * care least about. Every row states what it would return, because a filter the
89
+ * reader cannot price is a filter they will not drop.
90
+ */
91
+ export type MenuLoosenListProps = {
92
+ options: MenuLoosenOption[];
93
+ linkComponent?: MenuLinkComponent;
94
+ className?: string;
95
+ };
96
+
97
+ export function MenuLoosenList({
98
+ options,
99
+ linkComponent: LinkComponent = DefaultLink,
100
+ className,
101
+ }: MenuLoosenListProps) {
102
+ const rowClass =
103
+ "inline-flex h-9 items-center justify-between gap-3 rounded-full border border-border bg-card px-4 text-sm font-semibold transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none";
104
+
105
+ return (
106
+ <div className={cn("flex flex-col gap-2.5", className)}>
107
+ {options.map((option) => {
108
+ const body = (
109
+ <>
110
+ <span className="min-w-0 truncate">{option.label}</span>
111
+ <span className="shrink-0 font-bold tabular-nums text-accent">{option.gainLabel}</span>
112
+ </>
113
+ );
114
+
115
+ return option.href ? (
116
+ <LinkComponent key={option.key} href={option.href} className={rowClass}>
117
+ {body}
118
+ </LinkComponent>
119
+ ) : (
120
+ <button key={option.key} type="button" onClick={option.onSelect} className={rowClass}>
121
+ {body}
122
+ </button>
123
+ );
124
+ })}
125
+ </div>
126
+ );
127
+ }
@@ -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
+ }