@bearmenu/ui 0.8.10 → 0.8.12

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 (101) hide show
  1. package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
  2. package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
  3. package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
  4. package/dist/{chunk-UDN7UHJW.js → chunk-7A42J73Q.js} +3 -2
  5. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  6. package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
  7. package/dist/{chunk-H3BV4VAG.js → chunk-VAXOQS7W.js} +14 -2
  8. package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
  9. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  10. package/dist/components/alert-dialog.js +1 -1
  11. package/dist/components/badge.js +1 -1
  12. package/dist/components/button.d.ts +2 -2
  13. package/dist/components/button.js +1 -1
  14. package/dist/components/calendar.js +1 -1
  15. package/dist/components/carousel.js +1 -1
  16. package/dist/components/combobox.js +1 -1
  17. package/dist/components/concept-compare-rail.js +2 -2
  18. package/dist/components/concept-compare.d.ts +1 -1
  19. package/dist/components/concept-compare.js +31 -10
  20. package/dist/components/concept-convergence.js +2 -2
  21. package/dist/components/empty-state.js +1 -1
  22. package/dist/components/event-detail-panel.js +1 -1
  23. package/dist/components/event-list-row-wide.js +1 -1
  24. package/dist/components/filter-category-row.js +1 -1
  25. package/dist/components/glass-ground.d.ts +26 -31
  26. package/dist/components/glass-ground.js +1 -1
  27. package/dist/components/haptic-button.js +1 -1
  28. package/dist/components/input-group.d.ts +2 -2
  29. package/dist/components/input.d.ts +1 -1
  30. package/dist/components/item.d.ts +15 -5
  31. package/dist/components/item.js +15 -10
  32. package/dist/components/kitchen-dossier.d.ts +9 -7
  33. package/dist/components/kitchen-dossier.js +31 -25
  34. package/dist/components/link-button.js +1 -1
  35. package/dist/components/menu-family-block.d.ts +22 -17
  36. package/dist/components/menu-family-block.js +62 -61
  37. package/dist/components/menu-family-card.js +2 -4
  38. package/dist/components/menu-family-tiles.d.ts +19 -0
  39. package/dist/components/menu-family-tiles.js +57 -0
  40. package/dist/components/menu-hub-hero.js +18 -11
  41. package/dist/components/menu-index-list.d.ts +31 -12
  42. package/dist/components/menu-index-list.js +68 -31
  43. package/dist/components/menu-intent-hero.d.ts +1 -1
  44. package/dist/components/menu-item.js +3 -3
  45. package/dist/components/menu-mocks.d.ts +10 -3
  46. package/dist/components/menu-mocks.js +21 -9
  47. package/dist/components/menu-types.d.ts +33 -10
  48. package/dist/components/multi-select-combobox.js +2 -2
  49. package/dist/components/pagination.js +1 -1
  50. package/dist/components/photo-ground.d.ts +8 -2
  51. package/dist/components/photo-ground.js +1 -1
  52. package/dist/components/place-details-mobile.js +1 -1
  53. package/dist/components/searchable-select.js +1 -1
  54. package/dist/components/signature-chip.d.ts +2 -2
  55. package/dist/components/signature-chip.js +2 -2
  56. package/dist/components/sliding-panels.js +1 -1
  57. package/dist/components/top-ranking-entry.js +3 -3
  58. package/dist/components/venue-grade-dimensions.js +1 -1
  59. package/dist/components/venue-quotes.d.ts +1 -1
  60. package/dist/components/venue-quotes.js +1 -1
  61. package/dist/components/venue-wall.js +5 -3
  62. package/dist/components/weekday-theme-banner.d.ts +24 -3
  63. package/dist/components/weekday-theme-banner.js +63 -15
  64. package/package.json +1 -1
  65. package/src/components/badge.tsx +1 -1
  66. package/src/components/button.stories.tsx +25 -2
  67. package/src/components/button.tsx +14 -2
  68. package/src/components/colour-dictionary.stories.tsx +1 -1
  69. package/src/components/concept-compare-rail.tsx +2 -1
  70. package/src/components/concept-compare.stories.tsx +3 -3
  71. package/src/components/concept-compare.tsx +50 -14
  72. package/src/components/glass-ground.tsx +31 -56
  73. package/src/components/item.tsx +69 -45
  74. package/src/components/kitchen-dossier.stories.tsx +1 -1
  75. package/src/components/kitchen-dossier.tsx +60 -40
  76. package/src/components/menu-family-block.stories.tsx +3 -3
  77. package/src/components/menu-family-block.tsx +94 -98
  78. package/src/components/menu-family-card.stories.tsx +2 -2
  79. package/src/components/menu-family-card.tsx +6 -13
  80. package/src/components/menu-family-tiles.stories.tsx +54 -0
  81. package/src/components/menu-family-tiles.test.tsx +30 -0
  82. package/src/components/menu-family-tiles.tsx +132 -0
  83. package/src/components/menu-hub-hero.test.tsx +39 -0
  84. package/src/components/menu-hub-hero.tsx +30 -12
  85. package/src/components/menu-index-list.stories.tsx +6 -1
  86. package/src/components/menu-index-list.tsx +101 -54
  87. package/src/components/menu-intent-hero.tsx +1 -1
  88. package/src/components/menu-mocks.ts +32 -9
  89. package/src/components/menu-types.ts +32 -9
  90. package/src/components/photo-ground.tsx +14 -2
  91. package/src/components/signature-chip.tsx +3 -3
  92. package/src/components/venue-grade-dimensions.tsx +9 -2
  93. package/src/components/venue-quotes.stories.tsx +1 -1
  94. package/src/components/venue-quotes.tsx +4 -2
  95. package/src/components/venue-wall.tsx +9 -4
  96. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  97. package/src/components/weekday-theme-banner.tsx +95 -18
  98. package/dist/components/menu-intent-tiles.d.ts +0 -31
  99. package/dist/components/menu-intent-tiles.js +0 -50
  100. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  101. package/src/components/menu-intent-tiles.tsx +0 -76
@@ -52,63 +52,87 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
52
52
  );
53
53
  Item.displayName = "Item";
54
54
 
55
- const ItemMedia = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
56
- ({ className, ...props }, ref) => (
57
- <div
58
- ref={ref}
59
- data-slot="item-media"
60
- className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
61
- {...props}
62
- />
63
- )
55
+ /**
56
+ * The parts take `asChild` too. An `Item asChild` around a `<button>` makes
57
+ * the parts the button's children, and a button may hold only phrasing
58
+ * content — so the parts have to be able to render as `<span>`s there.
59
+ */
60
+ type ItemPartProps = React.HTMLAttributes<HTMLDivElement> & {
61
+ asChild?: boolean;
62
+ };
63
+
64
+ const ItemMedia = React.forwardRef<HTMLDivElement, ItemPartProps>(
65
+ ({ className, asChild, ...props }, ref) => {
66
+ const Comp = asChild ? Slot : "div";
67
+ return (
68
+ <Comp
69
+ ref={ref}
70
+ data-slot="item-media"
71
+ className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
72
+ {...props}
73
+ />
74
+ );
75
+ }
64
76
  );
65
77
  ItemMedia.displayName = "ItemMedia";
66
78
 
67
- const ItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
68
- ({ className, ...props }, ref) => (
69
- <div
70
- ref={ref}
71
- data-slot="item-content"
72
- className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
73
- {...props}
74
- />
75
- )
79
+ const ItemContent = React.forwardRef<HTMLDivElement, ItemPartProps>(
80
+ ({ className, asChild, ...props }, ref) => {
81
+ const Comp = asChild ? Slot : "div";
82
+ return (
83
+ <Comp
84
+ ref={ref}
85
+ data-slot="item-content"
86
+ className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
87
+ {...props}
88
+ />
89
+ );
90
+ }
76
91
  );
77
92
  ItemContent.displayName = "ItemContent";
78
93
 
79
- const ItemTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
80
- ({ className, ...props }, ref) => (
81
- <div
82
- ref={ref}
83
- data-slot="item-title"
84
- className={cn("truncate text-sm font-semibold text-foreground", className)}
85
- {...props}
86
- />
87
- )
94
+ const ItemTitle = React.forwardRef<HTMLDivElement, ItemPartProps>(
95
+ ({ className, asChild, ...props }, ref) => {
96
+ const Comp = asChild ? Slot : "div";
97
+ return (
98
+ <Comp
99
+ ref={ref}
100
+ data-slot="item-title"
101
+ className={cn("truncate text-sm font-semibold text-foreground", className)}
102
+ {...props}
103
+ />
104
+ );
105
+ }
88
106
  );
89
107
  ItemTitle.displayName = "ItemTitle";
90
108
 
91
- const ItemDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
92
- ({ className, ...props }, ref) => (
93
- <div
94
- ref={ref}
95
- data-slot="item-description"
96
- className={cn("truncate text-xs text-muted-foreground", className)}
97
- {...props}
98
- />
99
- )
109
+ const ItemDescription = React.forwardRef<HTMLDivElement, ItemPartProps>(
110
+ ({ className, asChild, ...props }, ref) => {
111
+ const Comp = asChild ? Slot : "div";
112
+ return (
113
+ <Comp
114
+ ref={ref}
115
+ data-slot="item-description"
116
+ className={cn("truncate text-xs text-muted-foreground", className)}
117
+ {...props}
118
+ />
119
+ );
120
+ }
100
121
  );
101
122
  ItemDescription.displayName = "ItemDescription";
102
123
 
103
- const ItemActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
104
- ({ className, ...props }, ref) => (
105
- <div
106
- ref={ref}
107
- data-slot="item-actions"
108
- className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
109
- {...props}
110
- />
111
- )
124
+ const ItemActions = React.forwardRef<HTMLDivElement, ItemPartProps>(
125
+ ({ className, asChild, ...props }, ref) => {
126
+ const Comp = asChild ? Slot : "div";
127
+ return (
128
+ <Comp
129
+ ref={ref}
130
+ data-slot="item-actions"
131
+ className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
132
+ {...props}
133
+ />
134
+ );
135
+ }
112
136
  );
113
137
  ItemActions.displayName = "ItemActions";
114
138
 
@@ -76,7 +76,7 @@ const meta: Meta<typeof KitchenDossier> = {
76
76
  docs: {
77
77
  description: {
78
78
  component:
79
- "One kitchen, told as a story on its own photograph. The room is clear at the top; a frosted caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
79
+ "One kitchen, told as a story on its own photograph. The room is clear at the top; an inked caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
80
80
  },
81
81
  },
82
82
  },
@@ -20,7 +20,7 @@ import { VenueQuotes } from "./venue-quotes";
20
20
  * The hub used to say this three times: a spotlight with everything the
21
21
  * digest knows, a ranked row with a grade, a card with the signature dish —
22
22
  * and the same kitchen led all three. This is the one card: the venue's
23
- * photograph clear at the top, a frosted caption rising from the foot with
23
+ * photograph clear at the top, an inked caption rising from the foot with
24
24
  * the identity (rating, name, type · area · reviews), and under it a PANEL
25
25
  * that says the three or four things the reviews established one at a time —
26
26
  * the dish it is known for, what people say, the grade — with segments at the
@@ -33,12 +33,12 @@ import { VenueQuotes } from "./venue-quotes";
33
33
  * bands. This is the reverse — the room is the subject and the facts are its
34
34
  * caption — which is `GlassGround` at its lightest and smallest: anchored to
35
35
  * the CAPTION, not the card, so it covers the identity, the panel and the
36
- * action plus a short lead above them and nothing else; a `sm` blur that
37
- * only takes the edge off the detail; ink that never passes 0.45; a text
38
- * shadow under the identity doing the rest. Everything above the caption is
39
- * the photograph, untouched. The panel is its own glass, so its rule reads
40
- * as a panel and not a hole. The same layout at every width; the desktop
41
- * card is wider, not turned on its side.
36
+ * action plus a short lead above them and nothing else; ink that never
37
+ * passes 0.6; a text shadow under the identity doing the rest. Everything
38
+ * above the caption is the photograph, untouched. The panel is a translucent
39
+ * ink pane (no backdrop blur see `GlassGround` for the measurement that
40
+ * removed it), so its rule reads as a panel and not a hole. The same layout
41
+ * at every width; the desktop card is wider, not turned on its side.
42
42
  *
43
43
  * ## Two targets, and everything else is "next"
44
44
  *
@@ -72,6 +72,8 @@ export type KitchenDossierLabels = {
72
72
  next: string;
73
73
  /** Dimension key → translated label. */
74
74
  dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
75
+ /** Beside the grade in the title row, e.g. "/10". Omit for the bare numeral. */
76
+ gradeUnit?: string;
75
77
  };
76
78
 
77
79
  export type KitchenDossierProps = {
@@ -99,10 +101,10 @@ export type KitchenDossierProps = {
99
101
  };
100
102
 
101
103
  /**
102
- * How far above the caption the glass begins — the ramp from clear photograph
103
- * to frost, ending exactly where the identity starts. Long, so the edge is a
104
- * gradient and not a line. The glass wrapper's `-top-32` is this same 128px;
105
- * the two must agree, or the identity sits on half-frost.
104
+ * How far above the caption the ink begins — the ramp from clear photograph
105
+ * to ink, ending exactly where the identity starts. Long, so the edge is a
106
+ * gradient and not a line. The ink wrapper's `-top-32` is this same 128px;
107
+ * the two must agree, or the identity sits on half-ink.
106
108
  */
107
109
  const GLASS_LEAD = "128px";
108
110
 
@@ -149,7 +151,7 @@ export function KitchenDossier({
149
151
  <span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
150
152
  )}
151
153
  {signature.why && (
152
- <p className="mt-1 line-clamp-2 font-serif text-[13px] italic leading-snug text-white/75">
154
+ <p className="mt-1 line-clamp-2 font-serif text-[13px] leading-snug text-white/75">
153
155
  {signature.why}
154
156
  </p>
155
157
  )}
@@ -202,25 +204,18 @@ export function KitchenDossier({
202
204
  .sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
203
205
  .slice(0, DIMENSION_LIMIT)
204
206
  : [];
205
- if (grade && (grade.overall || strongest.length > 0)) {
207
+ // The overall grade leads the title row now, so the part shows what the
208
+ // title cannot: the three strongest dimensions. No numeral repeated.
209
+ if (dimensions && strongest.length > 0) {
206
210
  parts.push(
207
211
  <Part key="grade" kicker={labels.theGrade}>
208
- <div className="flex items-end gap-4">
209
- {grade.overall && (
210
- <span className="text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums text-[oklch(0.82_0.15_85)]">
211
- {grade.overall}
212
- </span>
213
- )}
214
- {dimensions && strongest.length > 0 && (
215
- <VenueGradeDimensions
216
- className="min-w-0 flex-1"
217
- dimensions={dimensions}
218
- labels={labels.dimensions}
219
- order={strongest}
220
- formatScore={formatScore}
221
- />
222
- )}
223
- </div>
212
+ <VenueGradeDimensions
213
+ className="min-w-0"
214
+ dimensions={dimensions}
215
+ labels={labels.dimensions}
216
+ order={strongest}
217
+ formatScore={formatScore}
218
+ />
224
219
  </Part>
225
220
  );
226
221
  }
@@ -253,7 +248,7 @@ export function KitchenDossier({
253
248
  {/* The clear photograph, with the one fact that belongs on it. */}
254
249
  <div className="relative min-h-[184px] flex-1">
255
250
  {labels.openUntil && (
256
- <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-3 text-[11.5px] font-bold text-white backdrop-blur-[2px]">
251
+ <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-3 text-[11.5px] font-bold text-white">
257
252
  <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
258
253
  {labels.openUntil}
259
254
  </span>
@@ -271,8 +266,7 @@ export function KitchenDossier({
271
266
  >
272
267
  <GlassGround
273
268
  stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
274
- peak={0.4}
275
- blur="sm"
269
+ peak={0.6}
276
270
  />
277
271
  </div>
278
272
  <div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
@@ -282,16 +276,42 @@ export function KitchenDossier({
282
276
  {place.name}
283
277
  </LinkComponent>
284
278
  </h3>
285
- {rating !== undefined && (
286
- <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
287
- {rating.toFixed(1)}
288
- <Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
279
+ {/* The GRADE leads — the number the rail is named for, in the
280
+ grade's amber. Google's star used to sit here while the
281
+ grade waited in story part three; a card rated by BearMenu
282
+ cannot lead with a number BearMenu did not compute. The
283
+ star drops to the meta line, and the title row only falls
284
+ back to it for a kitchen with no grade. */}
285
+ {grade?.overall ? (
286
+ <span className="inline-flex shrink-0 items-baseline gap-1 text-[22px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)] lg:text-[26px]">
287
+ {grade.overall}
288
+ {labels.gradeUnit && (
289
+ <span className="text-[11px] font-bold text-white/70">{labels.gradeUnit}</span>
290
+ )}
289
291
  </span>
292
+ ) : (
293
+ rating !== undefined && (
294
+ <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
295
+ {rating.toFixed(1)}
296
+ <Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
297
+ </span>
298
+ )
290
299
  )}
291
300
  </div>
292
- {(meta || labels.reviewCount) && (
293
- <p className="mt-1 truncate text-[12.5px] text-white/70">
294
- {[meta, labels.reviewCount].filter(Boolean).join(" · ")}
301
+ {(meta || labels.reviewCount || (grade?.overall && rating !== undefined)) && (
302
+ <p className="mt-1 flex min-w-0 items-center gap-1 text-[12.5px] text-white/70">
303
+ {(meta || labels.reviewCount) && (
304
+ <span className="truncate">
305
+ {[meta, labels.reviewCount].filter(Boolean).join(" · ")}
306
+ </span>
307
+ )}
308
+ {grade?.overall && rating !== undefined && (
309
+ <span className="inline-flex shrink-0 items-center gap-1 tabular-nums">
310
+ {(meta || labels.reviewCount) && <span aria-hidden>·</span>}
311
+ {rating.toFixed(1)}
312
+ <Star className="h-3 w-3" aria-hidden />
313
+ </span>
314
+ )}
295
315
  </p>
296
316
  )}
297
317
  </div>
@@ -299,7 +319,7 @@ export function KitchenDossier({
299
319
  {parts.length > 0 && (
300
320
  <StoryPanel
301
321
  parts={parts}
302
- className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.35)] p-4 backdrop-blur-md"
322
+ className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.62)] p-4"
303
323
  />
304
324
  )}
305
325
 
@@ -36,7 +36,7 @@ const meta: Meta<typeof MenuFamilyBlock> = {
36
36
  docs: {
37
37
  description: {
38
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.",
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 two layers — the mosaic wearing the family's hue, and an ink gradient that is absent where the photography is the subject and full where the rows begin. (There was a masked `backdrop-filter` between them until 0.8.11; ten of them down a scroll cost the consumer's menu page 9 ms per frame, so the ink carries the type alone now.) Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows inked 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. One cost accepted: thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
40
40
  },
41
41
  },
42
42
  },
@@ -45,7 +45,7 @@ const meta: Meta<typeof MenuFamilyBlock> = {
45
45
  export default meta;
46
46
  type Story = StoryObj<typeof MenuFamilyBlock>;
47
47
 
48
- /** A stand-in photograph with enough variance that the frost has something to soften. */
48
+ /** A stand-in photograph with enough variance that the ink has something to sit on. */
49
49
  function photo(hue: number, label: string) {
50
50
  return (
51
51
  "data:image/svg+xml;utf8," +
@@ -78,7 +78,7 @@ export const Bare: Story = {
78
78
  },
79
79
  };
80
80
 
81
- /** Frost and ink run top to bottom: clear photography under the name, the rows on the frosted foot. */
81
+ /** The ink runs top to bottom: clear photography under the name, the rows on the inked foot. */
82
82
  export const Mobile: Story = {
83
83
  args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
84
84
  decorators: [
@@ -1,5 +1,6 @@
1
1
  import { Wallet } from "lucide-react";
2
2
  import { cn } from "../lib/utils";
3
+ import { buttonVariants } from "./button";
3
4
  import { HueChip } from "./hue-chip";
4
5
  import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
5
6
  import { Separator } from "./separator";
@@ -12,28 +13,27 @@ import type {
12
13
  } from "./menu-types";
13
14
 
14
15
  /**
15
- * MenuFamilyBlock — the cuisines index's family card. The mosaic is the ground.
16
+ * MenuFamilyBlock — the cuisines index's family card. One photograph is the ground.
16
17
  *
17
18
  * ## Three layers, and everything floats on them
18
19
  *
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:
20
+ * One venue photograph is the background for the ENTIRE card, edge to edge,
21
+ * and the facts sit on it. Three layers do the work:
21
22
  *
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.
23
+ * 1. **The photograph**, full-bleed, wearing the family's hue through a
24
+ * `mix-blend-color` layer. It was a mosaic of six; the design draws one
25
+ * plate, and six tiles under the ink read as texture rather than a room.
26
+ * 2. **An ink gradient** absent where the photography is the subject, full
27
+ * where the rows begin. This is what buys the contrast. There used to be a
28
+ * masked backdrop-filter under it; it was removed for the measurement
29
+ * recorded in `GlassGround` (73 blur layers cost `/menu` 9 ms per scrolled
30
+ * frame on a phone).
30
31
  *
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.
32
+ * On the phone the ink runs top to bottom: the name and the action sit on
33
+ * clear photography, the chips and the rows on the inked foot. On desktop the
34
+ * photo column on the left (a third) stays clear, and the row column on the
35
+ * right is opaque paper in the card's own dark three venue rows never sit
36
+ * on a photograph.
37
37
  *
38
38
  * ## The dark scope
39
39
  *
@@ -81,6 +81,12 @@ export type MenuFamilyBlockLabels = {
81
81
  priceBand: string;
82
82
  /** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
83
83
  browse: string;
84
+ /**
85
+ * The action from `lg`, where the photo column is a third of the card and
86
+ * "Browse Bakeries & patisseries" does not fit — e.g. "Browse". The full
87
+ * `browse` stays the accessible name.
88
+ */
89
+ browseShort?: string;
84
90
  /** Passed through to the venue rows. */
85
91
  kitchens: MenuKitchenListLabels;
86
92
  };
@@ -97,29 +103,22 @@ export type MenuFamilyBlockProps = {
97
103
  className?: string;
98
104
  };
99
105
 
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
106
  /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
105
107
  const GROUND = PHOTO_GROUND;
106
108
 
107
109
  /**
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
110
+ * The ink. Nearly absent at the top, heavier at the foot on the phone the
111
+ * top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
112
+ * `lg`, where the row column brings its own opaque paper anyway. It is the
117
113
  * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
118
114
  * lives in the app's stylesheet, not this package's, so a DS component reading
119
115
  * it renders nothing in Storybook and the other three consumers.
120
116
  */
121
117
  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%)]";
118
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,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%)]";
119
+
120
+ /** The row column's paper from `lg`: the card's dark, opaque. */
121
+ const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
123
122
 
124
123
  /**
125
124
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -148,10 +147,12 @@ export function MenuFamilyBlock({
148
147
  linkComponent: LinkComponent = DefaultLink,
149
148
  className,
150
149
  }: 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();
150
+ const [photo] = family.photoUrls ?? [];
151
+ // "+N more" counts past the rows shown, which are the venues the reader can
152
+ // see the photograph is one of them, not a venue on its own.
154
153
  const venues = family.venues ?? [];
154
+ const overflow = Math.max(family.venueCount - venues.length, 0);
155
+ const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
155
156
 
156
157
  return (
157
158
  <article
@@ -163,33 +164,26 @@ export function MenuFamilyBlock({
163
164
  className
164
165
  )}
165
166
  >
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)} />
167
+ {/* ── Layer 1: the photograph, wearing the family's colour ───────── */}
168
+ {/* One venue's photograph, or the family's initial on the ground. */}
169
+ <PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
182
170
 
183
- {/* ── Layer 3: the ink ────────────────────────────────────────────── */}
171
+ {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
184
172
  <span aria-hidden className={cn("absolute inset-0", INK)} />
185
173
 
186
174
  {/* ── 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)}>
175
+ <div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
176
+ {/* The photo column. On the phone it is the card's head, with the
177
+ overflow pill pinned to the corner and the photograph clear above
178
+ the name; on desktop the left third, its stack anchored to the foot. */}
179
+ <div
180
+ className={cn(
181
+ "relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
182
+ TEXT_SCRIM
183
+ )}
184
+ >
191
185
  {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">
186
+ <span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
193
187
  {labels.more(overflow)}
194
188
  </span>
195
189
  )}
@@ -197,55 +191,57 @@ export function MenuFamilyBlock({
197
191
  <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
198
192
  {family.name}
199
193
  </h3>
200
- <span className="mt-0.5 block text-[13px] font-medium tabular-nums text-white/70">
194
+ {/* The count and the band on one line: two facts about the family,
195
+ read together — "96 kitchens, 14–46 lei". */}
196
+ <span className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[13px] font-medium tabular-nums text-white/70">
201
197
  {labels.venueUnit(family.venueCount)}
198
+ {family.priceBandLabel && (
199
+ <span
200
+ className="inline-flex items-center gap-1"
201
+ aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
202
+ >
203
+ <Wallet className="h-3.5 w-3.5 shrink-0 text-white/60" aria-hidden />
204
+ {family.priceBandLabel}
205
+ </span>
206
+ )}
202
207
  </span>
203
208
 
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. */}
209
+ {/* The hub's one action on ink. No glyph: a chevron would have made
210
+ the pill one more row, and every venue row beside it ends in
211
+ exactly that chevron. */}
213
212
  <LinkComponent
214
213
  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"
214
+ aria-label={labels.browse}
215
+ className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
216
216
  >
217
- {labels.browse}
217
+ {labels.browseShort ? (
218
+ <>
219
+ <span className="lg:hidden">{labels.browse}</span>
220
+ <span className="hidden lg:inline">{labels.browseShort}</span>
221
+ </>
222
+ ) : (
223
+ labels.browse
224
+ )}
218
225
  </LinkComponent>
219
226
  </div>
220
227
 
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>
228
+ {/* The row column: chips, then the rows, on paper from `lg`. */}
229
+ <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
230
+ {family.chips && family.chips.length > 0 && (
231
+ <ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
232
+ {family.chips.map((chip) => (
233
+ /* Label only. The counts are VENUES beside a dish group's name,
234
+ which every reader takes for dishes. */
235
+ <HueChip key={chip.slug} as="li" hue={hue}>
236
+ {chip.label}
237
+ </HueChip>
238
+ ))}
239
+ </ul>
240
+ )}
245
241
 
246
242
  {venues.length > 0 && (
247
243
  <>
248
- <Separator className="my-3" />
244
+ <Separator className="my-3 via-white/15" />
249
245
  <MenuKitchenList
250
246
  kitchens={venues}
251
247
  labels={labels.kitchens}
@@ -281,12 +277,12 @@ export function MenuFamilyBlockSkeleton({
281
277
  className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
282
278
  aria-hidden
283
279
  >
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" />
280
+ <div className="grid grid-cols-1 lg:grid-cols-[34%_1fr]">
281
+ <div className="relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5">
282
+ <Skeleton className="absolute right-3.5 top-3.5 h-6 w-[72px] rounded-full lg:static lg:mb-3" />
287
283
  <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" />
284
+ <Skeleton className="mt-2 h-3.5 w-[150px]" />
285
+ <Skeleton className="mt-3.5 h-11 w-[160px] rounded-full" />
290
286
  </div>
291
287
  <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
292
288
  <div className="flex gap-1.5">