@bearmenu/ui 0.8.11 → 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 (86) hide show
  1. package/dist/{chunk-H3ZZDX5L.js → chunk-7A42J73Q.js} +1 -1
  2. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  3. package/dist/{chunk-JS3EINZD.js → chunk-VAXOQS7W.js} +12 -0
  4. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  5. package/dist/components/alert-dialog.js +1 -1
  6. package/dist/components/alert.d.ts +2 -2
  7. package/dist/components/avatar.d.ts +3 -3
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +2 -2
  10. package/dist/components/button.js +1 -1
  11. package/dist/components/calendar.js +1 -1
  12. package/dist/components/card.d.ts +4 -4
  13. package/dist/components/carousel.js +1 -1
  14. package/dist/components/combobox.js +1 -1
  15. package/dist/components/command.d.ts +8 -8
  16. package/dist/components/concept-compare-rail.js +2 -2
  17. package/dist/components/concept-compare.d.ts +1 -1
  18. package/dist/components/concept-compare.js +28 -7
  19. package/dist/components/concept-convergence.js +1 -1
  20. package/dist/components/date-input.d.ts +1 -1
  21. package/dist/components/dialog.d.ts +2 -2
  22. package/dist/components/empty-state.js +1 -1
  23. package/dist/components/event-detail-panel.js +1 -1
  24. package/dist/components/event-list-row-wide.js +1 -1
  25. package/dist/components/haptic-button.js +1 -1
  26. package/dist/components/input-group.d.ts +4 -4
  27. package/dist/components/input.d.ts +2 -2
  28. package/dist/components/item.d.ts +19 -9
  29. package/dist/components/item.js +15 -10
  30. package/dist/components/kbd.d.ts +2 -2
  31. package/dist/components/kitchen-dossier.d.ts +2 -0
  32. package/dist/components/kitchen-dossier.js +25 -18
  33. package/dist/components/link-button.js +1 -1
  34. package/dist/components/menu-family-block.d.ts +15 -9
  35. package/dist/components/menu-family-block.js +62 -59
  36. package/dist/components/menu-family-card.js +1 -1
  37. package/dist/components/menu-family-tiles.d.ts +19 -0
  38. package/dist/components/menu-family-tiles.js +57 -0
  39. package/dist/components/menu-hub-hero.js +8 -6
  40. package/dist/components/menu-index-list.d.ts +31 -12
  41. package/dist/components/menu-index-list.js +68 -31
  42. package/dist/components/menu-intent-hero.d.ts +1 -1
  43. package/dist/components/menu-mocks.d.ts +10 -3
  44. package/dist/components/menu-mocks.js +21 -9
  45. package/dist/components/menu-types.d.ts +33 -10
  46. package/dist/components/multi-select-combobox.js +1 -1
  47. package/dist/components/native-select.d.ts +2 -2
  48. package/dist/components/pagination.js +1 -1
  49. package/dist/components/photo-ground.d.ts +7 -1
  50. package/dist/components/photo-ground.js +1 -1
  51. package/dist/components/searchable-select.js +1 -1
  52. package/dist/components/select.d.ts +1 -1
  53. package/dist/components/sliding-panels.js +1 -1
  54. package/dist/components/snap-rail.d.ts +2 -2
  55. package/dist/components/spinner.d.ts +4 -4
  56. package/dist/components/tag.d.ts +4 -4
  57. package/dist/components/text.d.ts +3 -3
  58. package/dist/components/toggle.d.ts +1 -1
  59. package/dist/components/venue-grade-dimensions.js +1 -1
  60. package/dist/components/weekday-theme-banner.d.ts +24 -3
  61. package/dist/components/weekday-theme-banner.js +62 -14
  62. package/package.json +1 -1
  63. package/src/components/button.stories.tsx +25 -2
  64. package/src/components/button.tsx +12 -0
  65. package/src/components/colour-dictionary.stories.tsx +1 -1
  66. package/src/components/concept-compare.tsx +40 -9
  67. package/src/components/item.tsx +69 -45
  68. package/src/components/kitchen-dossier.tsx +45 -24
  69. package/src/components/menu-family-block.tsx +86 -79
  70. package/src/components/menu-family-tiles.stories.tsx +54 -0
  71. package/src/components/menu-family-tiles.test.tsx +30 -0
  72. package/src/components/menu-family-tiles.tsx +132 -0
  73. package/src/components/menu-hub-hero.tsx +13 -7
  74. package/src/components/menu-index-list.stories.tsx +6 -1
  75. package/src/components/menu-index-list.tsx +101 -54
  76. package/src/components/menu-intent-hero.tsx +1 -1
  77. package/src/components/menu-mocks.ts +32 -9
  78. package/src/components/menu-types.ts +32 -9
  79. package/src/components/photo-ground.tsx +13 -1
  80. package/src/components/venue-grade-dimensions.tsx +9 -2
  81. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  82. package/src/components/weekday-theme-banner.tsx +94 -17
  83. package/dist/components/menu-intent-tiles.d.ts +0 -31
  84. package/dist/components/menu-intent-tiles.js +0 -50
  85. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  86. package/src/components/menu-intent-tiles.tsx +0 -76
@@ -79,7 +79,7 @@ export type ConceptCompareFigure = {
79
79
  export type ConceptCompareLabels = {
80
80
  /** Small caps kicker, e.g. "DISH CONCEPT". */
81
81
  kind: string;
82
- /** The badge on the lowest-priced room, e.g. "CHEAPEST". */
82
+ /** The badge on the lowest-priced room, e.g. "CHEAPEST" — the fallback when a row carries no `badge`. */
83
83
  cheapest: string;
84
84
  /** The badge on every other room, e.g. "#2". Defaults to `#${rank}`. */
85
85
  rank?: (rank: number) => string;
@@ -277,7 +277,15 @@ export function ConceptCompare({
277
277
  >
278
278
  {rooms.map((room, index) => {
279
279
  const href = hrefs?.[room.place_slug];
280
- const isCheapest = room.price === cheapest;
280
+ // A winner room says what it won; a ranked room says its rank.
281
+ // The CHEAPEST badge — and only it — is green with the arrow: it
282
+ // is the census's signature claim, the one badge a diner scans
283
+ // for. Gated on the badge, not the price: drink concepts cluster
284
+ // on price, and a "most named" room at the minimum must not wear
285
+ // the cheapest's glyph.
286
+ const badge = room.badge ?? (room.price === cheapest ? labels.cheapest : rankOf(index + 1));
287
+ const isCheapest = badge === labels.cheapest;
288
+ const lead = room.lead ?? (isCheapest ? "price" : undefined);
281
289
  const roomClass = cn(
282
290
  /* Transparent on the phone — the card behind it is the room's
283
291
  photograph — with only its scrim and its line. A photograph of
@@ -312,7 +320,7 @@ export function ConceptCompare({
312
320
  )}
313
321
  >
314
322
  {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
315
- {isCheapest ? labels.cheapest : rankOf(index + 1)}
323
+ {badge}
316
324
  </span>
317
325
 
318
326
  {/* Name and rating on one line: the pin leads, the name takes
@@ -322,11 +330,25 @@ export function ConceptCompare({
322
330
  <b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
323
331
  {room.place_name}
324
332
  </b>
325
- {room.rating !== undefined && (
326
- <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
327
- <Star className="h-3 w-3 shrink-0" aria-hidden />
328
- <span className="tabular-nums">{room.rating}</span>
333
+ {/* The BearMenu grade, in the grade's amber — the number
334
+ the census computed, beside the kitchen it computed it
335
+ for. Google's star only for a kitchen with no grade. */}
336
+ {room.grade ? (
337
+ <span
338
+ className={cn(
339
+ "shrink-0 text-[14px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)]",
340
+ lead === "grade" && "text-[16px]"
341
+ )}
342
+ >
343
+ {room.grade}
329
344
  </span>
345
+ ) : (
346
+ room.rating !== undefined && (
347
+ <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
348
+ <Star className="h-3 w-3 shrink-0" aria-hidden />
349
+ <span className="tabular-nums">{room.rating}</span>
350
+ </span>
351
+ )
330
352
  )}
331
353
  </span>
332
354
  {room.place_area && (
@@ -343,7 +365,11 @@ export function ConceptCompare({
343
365
  is a 0.32-lightness grey that vanishes on inked photography. */}
344
366
  <Separator className="my-2.5 via-white/20" />
345
367
 
346
- <span className="flex items-baseline gap-2">
368
+ {/* The figures: the one the badge is about leads — the badge
369
+ says what the room won, the number is the evidence. Green
370
+ is the cheapest's alone (a dearest room's price in green
371
+ would read as cheap); the other winners lead in full white. */}
372
+ <span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
347
373
  <b
348
374
  className={cn(
349
375
  "text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
@@ -353,7 +379,12 @@ export function ConceptCompare({
353
379
  {formatPrice(room.price)}
354
380
  </b>
355
381
  {room.mentions !== undefined && room.mentions > 0 && (
356
- <span className="text-[11.5px] text-white/65">
382
+ <span
383
+ className={cn(
384
+ "text-[11.5px]",
385
+ lead === "mentions" ? "font-bold text-white" : "text-white/65"
386
+ )}
387
+ >
357
388
  {labels.mentions(room.mentions)}
358
389
  </span>
359
390
  )}
@@ -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
 
@@ -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 = {
@@ -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
  }
@@ -281,16 +276,42 @@ export function KitchenDossier({
281
276
  {place.name}
282
277
  </LinkComponent>
283
278
  </h3>
284
- {rating !== undefined && (
285
- <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
286
- {rating.toFixed(1)}
287
- <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
+ )}
288
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
+ )
289
299
  )}
290
300
  </div>
291
- {(meta || labels.reviewCount) && (
292
- <p className="mt-1 truncate text-[12.5px] text-white/70">
293
- {[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
+ )}
294
315
  </p>
295
316
  )}
296
317
  </div>
@@ -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,15 +13,16 @@ 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.
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.
24
26
  * 2. **An ink gradient** — absent where the photography is the subject, full
25
27
  * where the rows begin. This is what buys the contrast. There used to be a
26
28
  * masked backdrop-filter under it; it was removed for the measurement
@@ -29,10 +31,9 @@ import type {
29
31
  *
30
32
  * On the phone the ink runs top to bottom: the name and the action sit on
31
33
  * clear photography, the chips and the rows on the inked foot. On desktop the
32
- * same layers turn ninety degrees the photo column on the left stays clear,
33
- * the row column on the right is the inked one. The mosaic
34
- * runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
35
- * photograph too big to read as texture.
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.
36
37
  *
37
38
  * ## The dark scope
38
39
  *
@@ -80,6 +81,12 @@ export type MenuFamilyBlockLabels = {
80
81
  priceBand: string;
81
82
  /** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
82
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;
83
90
  /** Passed through to the venue rows. */
84
91
  kitchens: MenuKitchenListLabels;
85
92
  };
@@ -96,22 +103,22 @@ export type MenuFamilyBlockProps = {
96
103
  className?: string;
97
104
  };
98
105
 
99
- /** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
100
- const MOSAIC_TILES = 6;
101
- const PHONE_TILES = 4;
102
-
103
106
  /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
104
107
  const GROUND = PHOTO_GROUND;
105
108
 
106
109
  /**
107
- * The ink. Absent at the top, heavier at the foot on the phone; absent on the
108
- * left, full on the right from `lg`. 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
109
113
  * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
110
114
  * lives in the app's stylesheet, not this package's, so a DS component reading
111
115
  * it renders nothing in Storybook and the other three consumers.
112
116
  */
113
117
  const INK =
114
- "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)]";
115
122
 
116
123
  /**
117
124
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -140,10 +147,12 @@ export function MenuFamilyBlock({
140
147
  linkComponent: LinkComponent = DefaultLink,
141
148
  className,
142
149
  }: MenuFamilyBlockProps) {
143
- const photos = (family.photoUrls ?? []).slice(0, MOSAIC_TILES);
144
- const overflow = Math.max(family.venueCount - photos.length, 0);
145
- 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.
146
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();
147
156
 
148
157
  return (
149
158
  <article
@@ -155,30 +164,26 @@ export function MenuFamilyBlock({
155
164
  className
156
165
  )}
157
166
  >
158
- {/* ── Layer 1: the photographs, wearing the family's colour ──────── */}
159
- {/* A 2×2 on the phone, 3×2 from `lg`: at 1110px a four-tile grid makes
160
- each photograph too big to read as texture. With no photographs at
161
- all, one slot with the family's initial. */}
162
- <PhotoGround
163
- photos={photos.length > 0 ? photos : [undefined]}
164
- initials={initial}
165
- mosaicClassName={
166
- photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
167
- }
168
- phoneTiles={PHONE_TILES}
169
- gradient={gradient}
170
- />
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} />
171
170
 
172
171
  {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
173
172
  <span aria-hidden className={cn("absolute inset-0", INK)} />
174
173
 
175
174
  {/* ── The content, floating ───────────────────────────────────────── */}
176
- <div className="relative grid grid-cols-1 lg:grid-cols-[38%_1fr]">
177
- {/* The photo column. On the phone it is the card's head; on desktop
178
- the left third, with its stack anchored to the foot. */}
179
- <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
+ >
180
185
  {overflow > 0 && (
181
- <span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white 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">
182
187
  {labels.more(overflow)}
183
188
  </span>
184
189
  )}
@@ -186,55 +191,57 @@ export function MenuFamilyBlock({
186
191
  <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
187
192
  {family.name}
188
193
  </h3>
189
- <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">
190
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
+ )}
191
207
  </span>
192
208
 
193
- {/* The action, as a translucent pill on the photography. `bg-white/16`
194
- on the ink rather than `bg-accent`: ten filled accent buttons on
195
- one page is ten primary actions, and the pill reads as part of the
196
- picture rather than as a control laid on top of it.
197
-
198
- No glyph. An arrow read as "leaves the site" beside a label that
199
- already says where it goes, and a chevron would have made the pill
200
- one more row: every venue row to its right ends in exactly that
201
- 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. */}
202
212
  <LinkComponent
203
213
  href={family.href}
204
- className="mt-3.5 inline-flex h-10 w-max items-center rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white transition-colors hover:bg-white/25 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")}
205
216
  >
206
- {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
+ )}
207
225
  </LinkComponent>
208
226
  </div>
209
227
 
210
- {/* The inked column: chips, then the rows. */}
211
- <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
212
- <ul className="flex flex-wrap items-center gap-1.5">
213
- {family.priceBandLabel && (
214
- /* No hue: a fact about money, not about the family. In the dark
215
- scope `HueChip`'s `--muted` fallback is the dark pill the design
216
- draws. */
217
- <HueChip
218
- as="li"
219
- className="font-bold tabular-nums"
220
- aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
221
- icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
222
- >
223
- {family.priceBandLabel}
224
- </HueChip>
225
- )}
226
- {family.chips?.map((chip) => (
227
- /* Label only. The counts are VENUES beside a dish group's name,
228
- which every reader takes for dishes. */
229
- <HueChip key={chip.slug} as="li" hue={hue}>
230
- {chip.label}
231
- </HueChip>
232
- ))}
233
- </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
+ )}
234
241
 
235
242
  {venues.length > 0 && (
236
243
  <>
237
- <Separator className="my-3" />
244
+ <Separator className="my-3 via-white/15" />
238
245
  <MenuKitchenList
239
246
  kitchens={venues}
240
247
  labels={labels.kitchens}
@@ -270,12 +277,12 @@ export function MenuFamilyBlockSkeleton({
270
277
  className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
271
278
  aria-hidden
272
279
  >
273
- <div className="grid grid-cols-1 lg:grid-cols-[38%_1fr]">
274
- <div className="flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5">
275
- <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" />
276
283
  <Skeleton className="h-7 w-[60%]" />
277
- <Skeleton className="mt-2 h-3.5 w-[90px]" />
278
- <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" />
279
286
  </div>
280
287
  <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
281
288
  <div className="flex gap-1.5">
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuFamilyTiles } from "./menu-family-tiles";
3
+ import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof MenuFamilyTiles> = {
6
+ title: "Menu/MenuFamilyTiles",
7
+ component: MenuFamilyTiles,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[1100px] bg-background p-4">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The hub's doorways: every family the city browses by, as a grid of coloured tiles — the family's name, its venue count and one venue's photograph tilted off the corner — closed by one tile that opens the whole index. Two to a row on a phone, four from `lg`, so seven families and the index make two rows at every width.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof MenuFamilyTiles>;
28
+
29
+ export const Default: Story = { args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll } };
30
+
31
+ /** Two-up on a phone — seven families and the index in four rows. */
32
+ export const Mobile: Story = {
33
+ args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll },
34
+ // The grid's columns follow the viewport, not the wrapper.
35
+ parameters: { viewport: { defaultViewport: "mobile1" } },
36
+ decorators: [
37
+ (Story) => (
38
+ <div className="w-[390px] bg-background p-3">
39
+ <Story />
40
+ </div>
41
+ ),
42
+ ],
43
+ };
44
+
45
+ /** No photographs anywhere: every polaroid holds the family's initial. */
46
+ export const WithoutPhotos: Story = {
47
+ args: {
48
+ tiles: mockFamilyTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
49
+ all: { ...mockFamilyTilesAll, photoUrl: undefined },
50
+ },
51
+ };
52
+
53
+ /** The drinks tab's five going-out families, no index tile. */
54
+ export const Partial: Story = { args: { tiles: mockFamilyTiles.slice(0, 5) } };