@bearmenu/ui 0.8.11 → 0.8.13

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 (91) hide show
  1. package/dist/{chunk-24RFI4RQ.js → chunk-3NMG3KNA.js} +1 -1
  2. package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
  3. package/dist/{chunk-DTXNDLSE.js → chunk-FKNBZF5T.js} +15 -0
  4. package/dist/{chunk-H3ZZDX5L.js → chunk-P7XTW2CG.js} +2 -2
  5. package/dist/{chunk-JS3EINZD.js → chunk-VAXOQS7W.js} +12 -0
  6. package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
  7. package/dist/components/alert-dialog.js +1 -1
  8. package/dist/components/alert.d.ts +2 -2
  9. package/dist/components/avatar.d.ts +3 -3
  10. package/dist/components/badge.d.ts +1 -1
  11. package/dist/components/button.d.ts +2 -2
  12. package/dist/components/button.js +1 -1
  13. package/dist/components/calendar.js +1 -1
  14. package/dist/components/card.d.ts +2 -2
  15. package/dist/components/carousel.js +1 -1
  16. package/dist/components/combobox.js +1 -1
  17. package/dist/components/command.d.ts +15 -15
  18. package/dist/components/concept-compare-rail.js +3 -3
  19. package/dist/components/concept-compare.d.ts +1 -1
  20. package/dist/components/concept-compare.js +29 -8
  21. package/dist/components/concept-convergence.js +1 -1
  22. package/dist/components/dialog.d.ts +2 -2
  23. package/dist/components/empty-state.js +1 -1
  24. package/dist/components/event-detail-panel.js +1 -1
  25. package/dist/components/event-list-row-wide.js +1 -1
  26. package/dist/components/haptic-button.js +1 -1
  27. package/dist/components/input-group.d.ts +4 -4
  28. package/dist/components/input.d.ts +2 -2
  29. package/dist/components/item.d.ts +17 -7
  30. package/dist/components/item.js +15 -10
  31. package/dist/components/kbd.d.ts +2 -2
  32. package/dist/components/kitchen-dossier.d.ts +2 -0
  33. package/dist/components/kitchen-dossier.js +25 -18
  34. package/dist/components/link-button.js +1 -1
  35. package/dist/components/menu-family-block.d.ts +15 -9
  36. package/dist/components/menu-family-block.js +63 -60
  37. package/dist/components/menu-family-card.js +1 -1
  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 +8 -6
  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-kitchen-list.js +1 -1
  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 +1 -1
  49. package/dist/components/native-select.d.ts +2 -2
  50. package/dist/components/pagination.js +1 -1
  51. package/dist/components/photo-ground.d.ts +7 -1
  52. package/dist/components/photo-ground.js +1 -1
  53. package/dist/components/searchable-select.js +1 -1
  54. package/dist/components/select.d.ts +1 -1
  55. package/dist/components/sliding-panels.js +1 -1
  56. package/dist/components/snap-rail.d.ts +2 -7
  57. package/dist/components/snap-rail.js +1 -1
  58. package/dist/components/spinner.d.ts +4 -4
  59. package/dist/components/tag.d.ts +4 -4
  60. package/dist/components/text.d.ts +5 -5
  61. package/dist/components/toggle.d.ts +1 -1
  62. package/dist/components/venue-grade-dimensions.js +1 -1
  63. package/dist/components/weekday-theme-banner.d.ts +24 -3
  64. package/dist/components/weekday-theme-banner.js +62 -14
  65. package/package.json +1 -1
  66. package/src/components/button.stories.tsx +25 -2
  67. package/src/components/button.tsx +12 -0
  68. package/src/components/colour-dictionary.stories.tsx +1 -1
  69. package/src/components/concept-compare.tsx +40 -9
  70. package/src/components/item.tsx +69 -45
  71. package/src/components/kitchen-dossier.tsx +45 -24
  72. package/src/components/menu-family-block.tsx +86 -79
  73. package/src/components/menu-family-tiles.stories.tsx +54 -0
  74. package/src/components/menu-family-tiles.test.tsx +30 -0
  75. package/src/components/menu-family-tiles.tsx +132 -0
  76. package/src/components/menu-hub-hero.tsx +13 -7
  77. package/src/components/menu-index-list.stories.tsx +6 -1
  78. package/src/components/menu-index-list.tsx +101 -54
  79. package/src/components/menu-intent-hero.tsx +1 -1
  80. package/src/components/menu-kitchen-list.tsx +4 -1
  81. package/src/components/menu-mocks.ts +32 -9
  82. package/src/components/menu-types.ts +32 -9
  83. package/src/components/photo-ground.tsx +13 -1
  84. package/src/components/snap-rail.tsx +19 -0
  85. package/src/components/venue-grade-dimensions.tsx +9 -2
  86. package/src/components/weekday-theme-banner.stories.tsx +43 -4
  87. package/src/components/weekday-theme-banner.tsx +94 -17
  88. package/dist/components/menu-intent-tiles.d.ts +0 -31
  89. package/dist/components/menu-intent-tiles.js +0 -50
  90. package/src/components/menu-intent-tiles.stories.tsx +0 -54
  91. 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, in the markup at every width. That makes it the
18
+ * page's entire link-equity job, which in turn means the rich cards above
19
+ * are free to be a swipe rail on a phone: nothing a crawler needs waits on a
20
+ * swipe, 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 — mount the rows behind the expander on tap — and the
28
+ * page silently stops linking to twenty-six pages it is supposed to be the
29
+ * 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.
@@ -150,7 +150,10 @@ function KitchenRow({
150
150
  </span>
151
151
 
152
152
  <div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:w-[226px] lg:flex-none">
153
- <span className="truncate text-[14.5px] font-bold tracking-[-0.01em] lg:text-[15.5px]">
153
+ {/* One line with an ellipsis on a phone, where the row is the whole
154
+ width; two lines from `lg`, where the column is 226px and
155
+ "ADRIATICA GELATERIA ITALIANA" would otherwise lose its half. */}
156
+ <span className="truncate text-[14.5px] font-bold tracking-[-0.01em] lg:line-clamp-2 lg:whitespace-normal lg:text-[15.5px] lg:leading-tight">
154
157
  {place.name}
155
158
  </span>
156
159
  <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
@@ -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
@@ -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>
@@ -166,6 +166,14 @@ SnapRail.displayName = "SnapRail";
166
166
  * `onActiveChange` and wants the dots somewhere other than directly under it —
167
167
  * inside a foot band, say. Decorative, as on the rail: the rail is the control.
168
168
  */
169
+ /**
170
+ * Past this many pages the dots stop being a picture of the rail — twelve
171
+ * 6px dots with their gaps already run 138px, and a cuisines index has
172
+ * thirty-six — so the row becomes a counter, "7 / 36", which says the same
173
+ * two things (where, and how far) at any length.
174
+ */
175
+ const MAX_DOTS = 12;
176
+
169
177
  export function SnapRailDots({
170
178
  count,
171
179
  active,
@@ -175,6 +183,17 @@ export function SnapRailDots({
175
183
  active: number;
176
184
  className?: string;
177
185
  }) {
186
+ if (count > MAX_DOTS) {
187
+ return (
188
+ <div
189
+ aria-hidden
190
+ className={cn("flex justify-center text-[11px] tabular-nums text-muted-foreground", className)}
191
+ >
192
+ {active + 1} / {count}
193
+ </div>
194
+ );
195
+ }
196
+
178
197
  return (
179
198
  <div aria-hidden className={cn("flex justify-center gap-1.5", className)}>
180
199
  {Array.from({ length: count }, (_, index) => (
@@ -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>
@@ -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) => (