@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
@@ -1,23 +1,32 @@
1
- import { ChevronRight } from "lucide-react";
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronRight } from "lucide-react";
2
5
  import { cn } from "../lib/utils";
6
+ import { Button } from "./button";
3
7
  import { Separator } from "./separator";
4
8
  import { Skeleton } from "./skeleton";
5
9
  import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
10
 
7
11
  /**
8
- * MenuIndexList — the complete set, four across.
12
+ * MenuIndexList — the complete set: one column on a phone, three from `lg`.
9
13
  *
10
14
  * ## This block is why the page can afford to be progressive
11
15
  *
12
16
  * It carries a crawlable link to EVERY destination the city has — all
13
- * thirty-eight cuisines, not the ten the page draws in full below. That makes
14
- * it the page's entire link-equity job, which in turn means the rich blocks
15
- * underneath are free to load ten at a time behind a "show more": nothing a
16
- * crawler needs is hidden behind that control, because it is all up here.
17
+ * thirty-eight cuisines, not the eight the page draws in full above. That
18
+ * makes it the page's entire link-equity job, which in turn means the rich
19
+ * blocks are free to load eight at a time behind a "show more": nothing a
20
+ * crawler needs is hidden behind that control, because it is all here.
21
+ *
22
+ * The phone's own "show all" is CSS, not markup: every row is in the DOM and
23
+ * the ones past `collapsedCount` are hidden below `lg` until the button is
24
+ * pressed. A list that mounted its tail on tap would have taken twenty-six
25
+ * links out of the page for the crawler that never taps.
17
26
  *
18
- * Get this backwards — put the whole set behind the disclosure and only ten
19
- * links in the markup — and the page silently stops linking to twenty-eight
20
- * pages it is supposed to be the index for.
27
+ * Get this backwards — put the whole set behind the disclosure and only eight
28
+ * links in the markup — and the page silently stops linking to thirty pages
29
+ * it is supposed to be the index for.
21
30
  *
22
31
  * ## Ordered by count, and the counts overlap
23
32
  *
@@ -25,6 +34,10 @@ import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-ty
25
34
  * places, because a trattoria is Italian AND pizza AND international. So this
26
35
  * is a ranked list of ways in, never a partition, and no copy near it may total
27
36
  * the numbers or call them a breakdown.
37
+ *
38
+ * A client component for the one piece of state the expander needs; the
39
+ * `linkComponent` a server caller hands in must therefore be a client
40
+ * reference (`ClientLink`), not `next/link` itself.
28
41
  */
29
42
 
30
43
  /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
@@ -40,8 +53,15 @@ export type MenuIndexListProps = {
40
53
  title: string;
41
54
  /** One line under it, e.g. "Ordered by kitchens — every one has its own page". */
42
55
  subtitle?: string;
43
- /** Locale-aware integer formatter. Defaults to `String`. */
44
- formatCount?: (value: number) => string;
56
+ /**
57
+ * Rows shown on a phone before the expander; the rest are hidden below `lg`
58
+ * until it is pressed. Omit to show every row at every width.
59
+ */
60
+ collapsedCount?: number;
61
+ /** The expander's labels, e.g. "Show all 38 cuisines" / "Show fewer". */
62
+ expandLabels?: { showAll: string; showFewer: string };
63
+ /** The colour mark beside each name, keyed by slug. Falls back to the border colour. */
64
+ hues?: Record<string, string>;
45
65
  linkComponent?: MenuLinkComponent;
46
66
  className?: string;
47
67
  };
@@ -50,10 +70,16 @@ export function MenuIndexList({
50
70
  entries,
51
71
  title,
52
72
  subtitle,
53
- formatCount = String,
73
+ collapsedCount,
74
+ expandLabels,
75
+ hues,
54
76
  linkComponent: LinkComponent = DefaultLink,
55
77
  className,
56
78
  }: MenuIndexListProps) {
79
+ const [expanded, setExpanded] = useState(false);
80
+ const collapsible =
81
+ collapsedCount !== undefined && expandLabels !== undefined && entries.length > collapsedCount;
82
+
57
83
  return (
58
84
  <section
59
85
  className={cn(
@@ -68,55 +94,76 @@ export function MenuIndexList({
68
94
  )}
69
95
  </div>
70
96
 
71
- {/*
72
- Two columns on a phone, four from `lg`. One column at 390px turns
73
- thirty-eight rows into four screens of scrolling for a list whose whole
74
- job is to be scanned; two halves that, and the row still holds a name of
75
- realistic length beside its count.
76
- */}
77
- <ul className="grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
78
- {entries.map((entry) => (
79
- <li key={entry.slug}>
80
- {/* The DS `Separator`, above every row including the first.
81
- It was `border-t first:border-t-0 lg:first:border-t`, which
82
- only exempted the very FIRST entry — so in the two-column phone
83
- layout the top-left row had no rule and the top-right one, its
84
- own neighbour, did. Ruling every row is both uniform and simpler
85
- than restoring the exception per breakpoint. */}
86
- <Separator />
87
- <LinkComponent
88
- href={entry.href}
89
- className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
97
+ {/* One column on a phone — a row holds a name of realistic length, its
98
+ count and the chevron with room to spare, and the expander keeps the
99
+ list to twelve rows until asked. Three from `lg`. */}
100
+ <ul className="grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
101
+ {entries.map((entry, index) => {
102
+ const hue = hues?.[entry.slug];
103
+ return (
104
+ <li
105
+ key={entry.slug}
106
+ className={cn(
107
+ collapsible && !expanded && index >= collapsedCount && "max-lg:hidden"
108
+ )}
90
109
  >
91
- <span
92
- aria-hidden
93
- className={cn(
94
- "h-2.5 w-2.5 shrink-0 rounded-[3px]",
95
- !entry.gradient && "bg-border"
96
- )}
97
- style={entry.gradient ? { backgroundImage: entry.gradient } : undefined}
98
- />
99
- <span className="min-w-0 flex-1 truncate text-[13.5px]">{entry.name}</span>
100
- <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
101
- {formatCount(entry.count)}
102
- </span>
103
- <ChevronRight
104
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
105
- aria-hidden
106
- />
107
- </LinkComponent>
108
- </li>
109
- ))}
110
+ {/* The DS `Separator`, above every row including the first, so
111
+ the rule is uniform across columns. */}
112
+ <Separator />
113
+ <LinkComponent
114
+ href={entry.href}
115
+ className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
116
+ >
117
+ {/* The family's mark: a dot in its hue. */}
118
+ <span
119
+ aria-hidden
120
+ className={cn("h-2 w-2 shrink-0 rounded-full", !hue && !entry.gradient && "bg-border")}
121
+ style={
122
+ hue
123
+ ? { backgroundColor: hue }
124
+ : entry.gradient
125
+ ? { backgroundImage: entry.gradient }
126
+ : undefined
127
+ }
128
+ />
129
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
130
+ <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
131
+ {entry.countLabel ?? String(entry.count)}
132
+ </span>
133
+ <ChevronRight
134
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
135
+ aria-hidden
136
+ />
137
+ </LinkComponent>
138
+ </li>
139
+ );
140
+ })}
110
141
  </ul>
142
+
143
+ {collapsible && (
144
+ <Button
145
+ variant="outline"
146
+ fullWidth
147
+ className="mt-2 lg:hidden"
148
+ aria-expanded={expanded}
149
+ onClick={() => setExpanded((open) => !open)}
150
+ >
151
+ {expanded ? expandLabels.showFewer : expandLabels.showAll}
152
+ <ChevronDown
153
+ className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-180")}
154
+ aria-hidden
155
+ />
156
+ </Button>
157
+ )}
111
158
  </section>
112
159
  );
113
160
  }
114
161
 
115
162
  // ─── Skeleton ───────────────────────────────────────────────────────────────
116
163
 
117
- /** Two columns on a phone, four from `lg` — the grid the list itself renders. */
164
+ /** One column on a phone, three from `lg` — the grid the list itself renders. */
118
165
  export function MenuIndexListSkeleton({
119
- rows = 24,
166
+ rows = 12,
120
167
  className,
121
168
  }: {
122
169
  rows?: number;
@@ -132,12 +179,12 @@ export function MenuIndexListSkeleton({
132
179
  >
133
180
  <Skeleton className="h-[15px] w-[140px]" />
134
181
  <Skeleton className="mt-1.5 h-3 w-[240px]" />
135
- <ul className="mt-2 grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
182
+ <ul className="mt-2 grid grid-cols-1 lg:grid-cols-3 lg:gap-x-8">
136
183
  {Array.from({ length: rows }, (_, index) => (
137
184
  <li key={index}>
138
185
  <Separator />
139
186
  <div className="flex items-center gap-2.5 py-2.5">
140
- <Skeleton className="h-2.5 w-2.5 rounded-[3px]" />
187
+ <Skeleton className="h-2 w-2 rounded-full" />
141
188
  <Skeleton className="h-3.5 flex-1" />
142
189
  <Skeleton className="h-3 w-6" />
143
190
  </div>
@@ -55,7 +55,7 @@ export type MenuIntentHeroProps = {
55
55
  * Finished CSS gradient for the ground, e.g. `linear-gradient(140deg, …)`.
56
56
  *
57
57
  * A gradient rather than a token name, for the same reason `<MenuField>` and
58
- * `<MenuIntentTiles>` take one: the hue tables are app-owned, and a component
58
+ * `<MenuFamilyTiles>` take one: the hue tables are app-owned, and a component
59
59
  * referencing `var(--h-pizza)` renders an unstyled panel in any consumer that
60
60
  * does not define it. Missing, the panel falls back to the foreground colour
61
61
  * — visibly unstyled rather than white text on white.
@@ -25,7 +25,7 @@ import type {
25
25
  MenuFilterChip,
26
26
  MenuIndexEntry,
27
27
  MenuIntentFigure,
28
- MenuIntentTile,
28
+ MenuFamilyTile,
29
29
  MenuItemPreview,
30
30
  MenuKitchenPreview,
31
31
  MenuLoosenOption,
@@ -372,18 +372,41 @@ export const mockDrinkFamily: MenuFamilyPreview = {
372
372
  venue_photo_urls: [],
373
373
  };
374
374
 
375
- const intentGradient = (hue: number) =>
375
+ const familyGradient = (hue: number) =>
376
376
  `linear-gradient(150deg, oklch(0.52 0.14 ${hue}), oklch(0.43 0.13 ${hue - 4}))`;
377
377
 
378
- export const mockIntentTiles: MenuIntentTile[] = [
379
- { slug: "italian", label: "Italian tonight", caption: "247 kitchens", href: "/top/italian?open=now", gradient: intentGradient(40) },
380
- { slug: "burgeri", label: "Burgers", caption: "229 kitchens", href: "/top/burgeri", gradient: intentGradient(25) },
381
- { slug: "restaurante", label: "Open right now", caption: "by the hour", href: "/top/restaurante?open=now", gradient: intentGradient(60) },
382
- { slug: "brunch", label: "Brunch", caption: "314 kitchens", href: "/top/brunch", gradient: intentGradient(75) },
383
- { slug: "vegan", label: "Vegan", caption: "108 kitchens", href: "/top/vegan", gradient: intentGradient(148) },
384
- { slug: "centru", label: "In Centru", caption: "by neighbourhood", href: "/top/centru", gradient: intentGradient(300) },
378
+ /** A venue photograph stand-in: a tinted room, not a plate. */
379
+ const familyPhoto = (hue: number) =>
380
+ "data:image/svg+xml;utf8," +
381
+ encodeURIComponent(
382
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 28% 60%)"/><stop offset="1" stop-color="hsl(${hue} 22% 26%)"/></linearGradient></defs><rect width="320" height="320" fill="url(#g)"/><circle cx="100" cy="110" r="56" fill="hsl(${hue} 40% 82%)" opacity=".55"/><rect x="170" y="190" width="120" height="90" fill="hsl(${hue} 15% 18%)" opacity=".7"/></svg>`
383
+ );
384
+
385
+ /**
386
+ * The hub's seven cuisines, in the registry's order. The photograph is a
387
+ * venue's, never a plate's; the last one ships without, so the story shows
388
+ * the initial fallback beside six real tiles.
389
+ */
390
+ export const mockFamilyTiles: MenuFamilyTile[] = [
391
+ { slug: "italian", label: "Italian", countLabel: "66", href: "/menu/cuisines/italian", gradient: familyGradient(38), photoUrl: familyPhoto(30) },
392
+ { slug: "romanian", label: "Romanian", countLabel: "64", href: "/menu/cuisines/romanian", gradient: familyGradient(25), photoUrl: familyPhoto(10) },
393
+ { slug: "breakfast-brunch", label: "Breakfast & brunch", countLabel: "64", href: "/menu/cuisines/breakfast-brunch", gradient: familyGradient(82), photoUrl: familyPhoto(45) },
394
+ { slug: "vegetarian", label: "Vegetarian", countLabel: "64", href: "/menu/cuisines/vegetarian", gradient: familyGradient(148), photoUrl: familyPhoto(120) },
395
+ { slug: "pizza", label: "Pizza", countLabel: "63", href: "/menu/cuisines/pizza", gradient: familyGradient(58), photoUrl: familyPhoto(20) },
396
+ { slug: "vegan", label: "Vegan", countLabel: "38", href: "/menu/cuisines/vegan", gradient: familyGradient(135), photoUrl: familyPhoto(100) },
397
+ { slug: "bakery", label: "Bakeries & patisseries", countLabel: "65", href: "/menu/cuisines/bakery", gradient: familyGradient(72) },
385
398
  ];
386
399
 
400
+ /** The closing tile: stone, so it reads as the index and not as an eighth cuisine. */
401
+ export const mockFamilyTilesAll: MenuFamilyTile = {
402
+ slug: "all",
403
+ label: "All 38 cuisines",
404
+ countLabel: "The full index",
405
+ href: "/menu/cuisines",
406
+ gradient: "linear-gradient(150deg, oklch(0.50 0.03 70), oklch(0.40 0.028 64))",
407
+ photoUrl: familyPhoto(60),
408
+ };
409
+
387
410
  /** Formatters the stories pass. Real callers inject locale-aware ones. */
388
411
  export const mockFormatters = {
389
412
  price: (value: number) => `${value} lei`,
@@ -134,6 +134,20 @@ export type MenuConceptRow = {
134
134
  featured?: boolean
135
135
  /** Portion, already formatted with its unit, e.g. "260g". */
136
136
  portion_label?: string
137
+ /**
138
+ * What this room WON — "CHEAPEST", "MOST NAMED", "HIGHEST GRADE",
139
+ * "DEAREST" — already localised. A card whose rooms are four winners rather
140
+ * than four ranks sets one per room; without it the card falls back to
141
+ * "cheapest, then #2, #3, #4".
142
+ */
143
+ badge?: string
144
+ /** Which figure the badge is about; that figure is emphasised. */
145
+ lead?: 'price' | 'mentions' | 'grade'
146
+ /**
147
+ * The venue's BearMenu grade, pre-formatted ("9.2"). Takes the star's
148
+ * place beside the kitchen's name; `rating` (Google's) shows only without it.
149
+ */
150
+ grade?: string
137
151
  }
138
152
 
139
153
  /**
@@ -203,17 +217,21 @@ export type MenuVenueSignature = {
203
217
  }
204
218
 
205
219
  /**
206
- * An intent tile — a family plus a constraint, resolving to a category page.
220
+ * A family tile — a cuisine on the dishes tab, a going-out family on the
221
+ * drinks tab — as the hub's doorway grid draws it: a hue, a name, a venue
222
+ * count and one venue's photograph.
207
223
  *
208
- * Only about thirty-two of the thirty-eight cuisines have a page, so `href` is
209
- * required: a tile that cannot resolve to a real destination is not rendered
210
- * rather than linking nowhere.
224
+ * `href` is required: a tile that cannot resolve to a real destination is not
225
+ * rendered rather than linking nowhere.
211
226
  */
212
- export type MenuIntentTile = {
227
+ export type MenuFamilyTile = {
213
228
  slug: string
214
229
  label: string
215
- /** Supporting line, e.g. "247 kitchens". Already formatted by the caller. */
216
- caption?: string
230
+ /**
231
+ * The venue count, already formatted by the caller — "64", "1,204" — because
232
+ * thousands separators are locale-dependent and the DS owns no locale.
233
+ */
234
+ countLabel?: string
217
235
  href: string
218
236
  /**
219
237
  * Finished CSS gradient for the tile's ground, e.g.
@@ -226,8 +244,8 @@ export type MenuIntentTile = {
226
244
  * — visible and obviously unstyled, rather than white text on white.
227
245
  */
228
246
  gradient?: string
229
- /** Icon node, resolved by the caller the DS does not pick icons for it. */
230
- icon?: ReactNode
247
+ /** Pre-resolved VENUE photograph. Venue photography is real; item photography is not. */
248
+ photoUrl?: string
231
249
  }
232
250
 
233
251
  /**
@@ -351,6 +369,11 @@ export type MenuIndexEntry = {
351
369
  slug: string
352
370
  name: string
353
371
  count: number
372
+ /**
373
+ * `count`, already formatted ("1,204"). The list is a client component and a
374
+ * formatter cannot cross into it from a server caller; a string can.
375
+ */
376
+ countLabel?: string
354
377
  href: string
355
378
  /** Finished CSS gradient for the mark. Falls back to the border colour. */
356
379
  gradient?: string
@@ -14,7 +14,7 @@ import { cn } from "../lib/utils";
14
14
  *
15
15
  * It owns the photographs and their colour: the ground, the tiles, the fallback
16
16
  * initial, the family's hue and an optional uniform dark overlay. It does NOT
17
- * own the glass — the scrims and frosted bands that carry a card's type run in
17
+ * own the ink — the scrims and bands that carry a card's type run in
18
18
  * a different direction on every card (down the family block's phone, across
19
19
  * its desktop, in from both ends of the concept card) and are the card's own.
20
20
  *
@@ -62,6 +62,12 @@ export type PhotoGroundProps = {
62
62
  overlayClassName?: string;
63
63
  /** How eagerly the first photograph loads. `stack` grounds are above the fold. */
64
64
  loading?: "lazy" | "eager";
65
+ /**
66
+ * Classes on the fallback initial — its size, when the ground is smaller
67
+ * than a card. The 64px default fills a card cell; a tile's 88px polaroid
68
+ * passes `text-[44px]`.
69
+ */
70
+ glyphClassName?: string;
65
71
  className?: string;
66
72
  };
67
73
 
@@ -76,6 +82,7 @@ export function PhotoGround({
76
82
  overlay,
77
83
  overlayClassName,
78
84
  loading = "lazy",
85
+ glyphClassName,
79
86
  className,
80
87
  }: PhotoGroundProps) {
81
88
  const initialOf = (index: number) =>
@@ -100,7 +107,12 @@ export function PhotoGround({
100
107
  photograph is not a broken image, and drawing one as a placeholder
101
108
  makes a real city look unfinished. */
102
109
  <span className="absolute inset-0 flex items-center justify-center">
103
- <span className="font-serif text-[64px] font-semibold leading-none text-white/15">
110
+ <span
111
+ className={cn(
112
+ "font-serif text-[64px] font-semibold leading-none text-white/15",
113
+ glyphClassName
114
+ )}
115
+ >
104
116
  {initialOf(index)}
105
117
  </span>
106
118
  </span>
@@ -19,8 +19,8 @@ import { cn } from "../lib/utils";
19
19
  *
20
20
  * `surface="image"` is for card covers, where the chip sits over the photo
21
21
  * rather than in the caption block. The accent-on-canvas palette has no
22
- * contrast guarantee over an arbitrary photo, so it swaps to the same frosted
23
- * fill the place badge uses there and keeps the dashed edge — which is what
22
+ * contrast guarantee over an arbitrary photo, so it swaps to the same solid
23
+ * ink fill the place badge uses there and keeps the dashed edge — which is what
24
24
  * says "there is evidence behind this" — in the inverted colour.
25
25
  *
26
26
  * The label is passed in: the DS owns no translation keys.
@@ -64,7 +64,7 @@ export function SignatureChip({
64
64
  shared,
65
65
  "inline-flex items-center rounded-full border border-dashed",
66
66
  onImage
67
- ? "border-background/70 bg-foreground/70 text-background backdrop-blur-sm hover:bg-foreground/85"
67
+ ? "border-background/70 bg-foreground/80 text-background hover:bg-foreground/90"
68
68
  : "border-accent/40 text-accent hover:bg-accent/10",
69
69
  "focus-visible:outline-none focus-visible:ring-2",
70
70
  onImage ? "focus-visible:ring-background/70" : "focus-visible:ring-accent/40",
@@ -74,12 +74,19 @@ export function VenueGradeDimensions({
74
74
  <dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
75
75
  {formatScore(value)}
76
76
  </dd>
77
+ {/* The track is `currentColor` at low alpha rather than `bg-muted`:
78
+ on the dossier's ink `--muted` is within a hair of the ground
79
+ and the empty bar vanished. */}
77
80
  <div
78
81
  aria-hidden
79
- className="relative block h-[3px] overflow-hidden rounded-full bg-muted"
82
+ className="relative block h-[3px] overflow-hidden rounded-full bg-current/15"
80
83
  >
84
+ {/* `bg-[image:…]`, not `bg-[…]`: `--gradient-primary` is a
85
+ linear-gradient, and as a background-COLOR it is an invalid
86
+ declaration the browser drops — the fill rendered at the right
87
+ width with no paint at all. */}
81
88
  <span
82
- className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary,currentColor)]"
89
+ className="absolute inset-y-0 left-0 rounded-full bg-current bg-[image:var(--gradient-primary)]"
83
90
  style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
84
91
  />
85
92
  </div>
@@ -19,7 +19,7 @@ const meta: Meta<typeof VenueQuotes> = {
19
19
  docs: {
20
20
  description: {
21
21
  component:
22
- "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif italic against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
22
+ "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in quoted serif against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
23
23
  },
24
24
  },
25
25
  },
@@ -6,7 +6,7 @@ import type { CitedText } from "./place-types";
6
6
  *
7
7
  * These are `reviews_digest.highlights`: one to three short positives already
8
8
  * resolved into the request locale. They are the only place on a hub where a
9
- * reviewer speaks in their own register, so they are set in serif italic
9
+ * reviewer speaks in their own register, so they are set in quoted serif
10
10
  * against the sans everything else uses.
11
11
  *
12
12
  * `onCite` turns a quote into a button that opens the reviews behind it —
@@ -39,8 +39,10 @@ export function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: Ven
39
39
  const citations = quote.review_ids?.length ?? 0;
40
40
  const canCite = !!onCite && citations > 0;
41
41
 
42
+ // Upright, quoted: the italic face was the only reason the consumer's
43
+ // menu page loaded 281 KB of Fraunces italic at navigation time.
42
44
  const text = (
43
- <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] italic leading-[1.45] text-muted-foreground">
45
+ <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] leading-[1.45] text-muted-foreground">
44
46
  {`“${quote.text}”`}
45
47
  </blockquote>
46
48
  );
@@ -42,6 +42,9 @@ export type VenueWallProps = {
42
42
  linkComponent?: MenuLinkComponent;
43
43
  };
44
44
 
45
+ const WALL_CELLS = 18;
46
+ const PHONE_CELLS = 6;
47
+
45
48
  export function VenueWall({
46
49
  places,
47
50
  hrefs,
@@ -64,8 +67,10 @@ export function VenueWall({
64
67
  >
65
68
  {tileUrls?.length ? (
66
69
  <>
67
- <div aria-hidden className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-6">
68
- {Array.from({ length: 18 }, (_, cell) => (
70
+ <div aria-hidden className="absolute inset-0 grid auto-rows-fr grid-cols-3 gap-0.5 lg:grid-cols-6">
71
+ {/* Six cells on the phone, eighteen from `lg` — the hero's rule:
72
+ `hidden` keeps the lazy stills past the sixth from loading. */}
73
+ {Array.from({ length: WALL_CELLS }, (_, cell) => (
69
74
  // eslint-disable-next-line @next/next/no-img-element
70
75
  <img
71
76
  key={cell}
@@ -73,7 +78,7 @@ export function VenueWall({
73
78
  alt=""
74
79
  loading="lazy"
75
80
  decoding="async"
76
- className="h-full w-full object-cover"
81
+ className={cn("h-full w-full object-cover", cell >= PHONE_CELLS && "hidden lg:block")}
77
82
  />
78
83
  ))}
79
84
  </div>
@@ -151,7 +156,7 @@ export function VenueWall({
151
156
  );
152
157
 
153
158
  const chipClasses =
154
- "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/10 py-1 pl-1 pr-3.5 backdrop-blur-[6px]";
159
+ "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/[0.14] py-1 pl-1 pr-3.5";
155
160
 
156
161
  return (
157
162
  <li key={place.slug}>
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Calendar, PanelRightOpen } from "lucide-react";
2
+ import { Calendar, ChevronRight } from "lucide-react";
3
3
  import { Button } from "./button";
4
4
  import { WeekdayThemeBanner, type WeekdayThemeDay } from "./weekday-theme-banner";
5
5
 
@@ -56,9 +56,9 @@ export const Default: Story = {
56
56
  body: "186 of them across 11 kitchens, from 32 lei. Two do a vegetarian version and four keep the grill on past midnight.",
57
57
  weekLabel: "THE WEEK",
58
58
  action: (
59
- <Button size="sm" variant="secondary">
60
- <PanelRightOpen className="h-3.5 w-3.5" />
59
+ <Button size="pill" variant="ink">
61
60
  See today&apos;s picks
61
+ <ChevronRight aria-hidden />
62
62
  </Button>
63
63
  ),
64
64
  actionNote: "Bruno picks nine, ranked on review mentions",
@@ -66,6 +66,40 @@ export const Default: Story = {
66
66
  },
67
67
  };
68
68
 
69
+ /**
70
+ * The phone: no labelled button — the banner is the target and the chevron
71
+ * on the right is the signal, so the banner stays as tall as its title.
72
+ */
73
+ export const CompactAction: Story = {
74
+ args: {
75
+ days: days.map((day) => ({ ...day, name: `${day.name} in Cluj-Napoca`, shortHeadline: day.name })),
76
+ activeDay: 3,
77
+ imageUrl:
78
+ "data:image/svg+xml;utf8," +
79
+ encodeURIComponent(
80
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="630"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(30 30% 40%)"/><stop offset="1" stop-color="hsl(20 40% 20%)"/></linearGradient></defs><rect width="1440" height="630" fill="url(#g)"/><circle cx="1000" cy="330" r="220" fill="hsl(40 60% 70%)"/><circle cx="1000" cy="330" r="150" fill="hsl(25 70% 45%)"/></svg>'
81
+ ),
82
+ kicker: "TODAY'S THEME",
83
+ kickerIcon: <Calendar className="h-3 w-3" />,
84
+ weekLabel: "THE WEEK",
85
+ action: (
86
+ <Button size="pill" variant="ink">
87
+ See today&apos;s picks
88
+ <ChevronRight aria-hidden />
89
+ </Button>
90
+ ),
91
+ compactAction: { label: "See today's picks", onClick: () => {} },
92
+ },
93
+ parameters: { viewport: { defaultViewport: "mobile1" } },
94
+ decorators: [
95
+ (Story) => (
96
+ <div className="w-[390px] bg-background p-3">
97
+ <Story />
98
+ </div>
99
+ ),
100
+ ],
101
+ };
102
+
69
103
  /** The drinks tab, same component. */
70
104
  export const DrinksTab: Story = {
71
105
  args: {
@@ -101,7 +135,12 @@ export const Mobile: Story = {
101
135
  kickerIcon: <Calendar className="h-3 w-3" />,
102
136
  body: "186 of them across 11 kitchens, from 32 lei.",
103
137
  weekLabel: "THE WEEK",
104
- action: <Button size="sm" variant="secondary">See today&apos;s picks</Button>,
138
+ action: (
139
+ <Button size="pill" variant="ink">
140
+ See today&apos;s picks
141
+ <ChevronRight aria-hidden />
142
+ </Button>
143
+ ),
105
144
  },
106
145
  decorators: [
107
146
  (Story) => (