@bearmenu/ui 0.8.16 → 0.8.18

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 (151) hide show
  1. package/dist/{chunk-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
  2. package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
  3. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  4. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  5. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  6. package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
  7. package/dist/chunk-LYREUZH6.js +156 -0
  8. package/dist/chunk-PW247TG2.js +81 -0
  9. package/dist/chunk-QY66ME5B.js +207 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +1 -1
  14. package/dist/components/alert.d.ts +2 -2
  15. package/dist/components/badge.d.ts +1 -1
  16. package/dist/components/badge.js +1 -1
  17. package/dist/components/button.d.ts +1 -1
  18. package/dist/components/button.js +1 -1
  19. package/dist/components/calendar.js +1 -1
  20. package/dist/components/card.d.ts +2 -2
  21. package/dist/components/carousel.js +1 -1
  22. package/dist/components/coach-marks.d.ts +63 -0
  23. package/dist/components/coach-marks.js +162 -0
  24. package/dist/components/combobox.js +1 -1
  25. package/dist/components/command.d.ts +15 -15
  26. package/dist/components/concept-compare-rail.js +3 -3
  27. package/dist/components/concept-compare.js +3 -3
  28. package/dist/components/concept-convergence.js +2 -2
  29. package/dist/components/doorway-tiles.d.ts +11 -0
  30. package/dist/components/doorway-tiles.js +5 -3
  31. package/dist/components/empty-state.js +1 -1
  32. package/dist/components/event-card.d.ts +22 -4
  33. package/dist/components/event-card.js +95 -50
  34. package/dist/components/event-day-header.d.ts +5 -3
  35. package/dist/components/event-day-header.js +12 -4
  36. package/dist/components/event-detail-panel.js +2 -2
  37. package/dist/components/event-door.d.ts +41 -0
  38. package/dist/components/event-door.js +65 -0
  39. package/dist/components/event-hub-hero.d.ts +10 -2
  40. package/dist/components/event-hub-hero.js +11 -5
  41. package/dist/components/event-index-row.js +1 -1
  42. package/dist/components/event-list-row-wide.js +2 -2
  43. package/dist/components/event-list-row.js +1 -1
  44. package/dist/components/event-tonight-strip.d.ts +50 -0
  45. package/dist/components/event-tonight-strip.js +67 -0
  46. package/dist/components/facet-group.d.ts +8 -1
  47. package/dist/components/facet-group.js +3 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/hue-chip.js +1 -1
  50. package/dist/components/item.d.ts +2 -2
  51. package/dist/components/item.js +4 -1
  52. package/dist/components/kitchen-dossier.js +1 -1
  53. package/dist/components/link-button.js +1 -1
  54. package/dist/components/menu-family-block.d.ts +8 -1
  55. package/dist/components/menu-family-block.js +101 -68
  56. package/dist/components/menu-family-card.js +2 -2
  57. package/dist/components/menu-filter-chips.d.ts +12 -3
  58. package/dist/components/menu-filter-chips.js +22 -4
  59. package/dist/components/menu-hub-hero.d.ts +7 -1
  60. package/dist/components/menu-hub-hero.js +2 -1
  61. package/dist/components/menu-index-list.js +1 -1
  62. package/dist/components/menu-item.js +3 -3
  63. package/dist/components/menu-kitchen-list.d.ts +14 -2
  64. package/dist/components/menu-kitchen-list.js +1 -1
  65. package/dist/components/multi-select-combobox.js +2 -2
  66. package/dist/components/pagination.js +1 -1
  67. package/dist/components/photo-ground.d.ts +7 -1
  68. package/dist/components/photo-ground.js +1 -1
  69. package/dist/components/place-card.js +2 -2
  70. package/dist/components/place-details-mobile.js +1 -1
  71. package/dist/components/poster-stack.d.ts +36 -3
  72. package/dist/components/poster-stack.js +49 -18
  73. package/dist/components/searchable-select.js +1 -1
  74. package/dist/components/section-header.d.ts +7 -1
  75. package/dist/components/section-header.js +11 -7
  76. package/dist/components/signature-chip.js +2 -2
  77. package/dist/components/sliding-panels.js +1 -1
  78. package/dist/components/snap-rail.d.ts +2 -2
  79. package/dist/components/snap-rail.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag.d.ts +2 -2
  82. package/dist/components/text.d.ts +3 -3
  83. package/dist/components/timeline-day.d.ts +54 -0
  84. package/dist/components/timeline-day.js +65 -0
  85. package/dist/components/timeline-hour-puck.d.ts +47 -0
  86. package/dist/components/timeline-hour-puck.js +3 -0
  87. package/dist/components/timeline-hour.d.ts +96 -0
  88. package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
  89. package/dist/components/timeline-playhead.js +1 -3
  90. package/dist/components/toggle-group.js +9 -4
  91. package/dist/components/toggle.d.ts +1 -1
  92. package/dist/components/toggle.js +1 -1
  93. package/dist/components/top-ranking-entry.js +3 -3
  94. package/package.json +1 -1
  95. package/src/components/badge.stories.tsx +31 -1
  96. package/src/components/badge.tsx +2 -0
  97. package/src/components/button.stories.tsx +5 -4
  98. package/src/components/button.test.tsx +11 -1
  99. package/src/components/button.tsx +16 -5
  100. package/src/components/coach-marks.stories.tsx +79 -0
  101. package/src/components/coach-marks.test.tsx +140 -0
  102. package/src/components/coach-marks.tsx +252 -0
  103. package/src/components/doorway-tiles.stories.tsx +34 -0
  104. package/src/components/doorway-tiles.test.tsx +27 -0
  105. package/src/components/doorway-tiles.tsx +30 -2
  106. package/src/components/event-card.tsx +70 -13
  107. package/src/components/event-day-header.tsx +19 -7
  108. package/src/components/event-door.stories.tsx +88 -0
  109. package/src/components/event-door.tsx +97 -0
  110. package/src/components/event-hub-hero.tsx +21 -3
  111. package/src/components/event-tonight-strip.stories.tsx +78 -0
  112. package/src/components/event-tonight-strip.test.tsx +59 -0
  113. package/src/components/event-tonight-strip.tsx +134 -0
  114. package/src/components/facet-group.tsx +9 -0
  115. package/src/components/hue-chip.test.tsx +6 -3
  116. package/src/components/hue-chip.tsx +5 -2
  117. package/src/components/item.tsx +4 -1
  118. package/src/components/menu-family-block.stories.tsx +12 -0
  119. package/src/components/menu-family-block.tsx +53 -20
  120. package/src/components/menu-filter-chips.tsx +32 -4
  121. package/src/components/menu-hub-hero.test.tsx +13 -0
  122. package/src/components/menu-hub-hero.tsx +9 -2
  123. package/src/components/menu-kitchen-list.tsx +45 -6
  124. package/src/components/photo-ground.tsx +7 -0
  125. package/src/components/poster-stack.stories.tsx +43 -1
  126. package/src/components/poster-stack.tsx +98 -19
  127. package/src/components/section-header.test.tsx +15 -0
  128. package/src/components/section-header.tsx +26 -12
  129. package/src/components/snap-rail.tsx +6 -1
  130. package/src/components/timeline-day.stories.tsx +201 -0
  131. package/src/components/timeline-day.test.tsx +78 -0
  132. package/src/components/timeline-day.tsx +114 -0
  133. package/src/components/timeline-hour-puck.tsx +139 -0
  134. package/src/components/timeline-hour.tsx +266 -0
  135. package/src/components/timeline-playhead.tsx +1 -1
  136. package/src/components/toggle-group.stories.tsx +37 -1
  137. package/src/components/toggle-group.tsx +11 -6
  138. package/src/components/toggle.tsx +8 -0
  139. package/dist/chunk-NSNEANOQ.js +0 -104
  140. package/dist/chunk-SXQZ7DLH.js +0 -167
  141. package/dist/components/hour-band.d.ts +0 -66
  142. package/dist/components/hour-band.js +0 -81
  143. package/dist/components/hour-group.d.ts +0 -67
  144. package/dist/components/hour-group.js +0 -59
  145. package/dist/components/timeline-band.d.ts +0 -64
  146. package/src/components/hour-band.stories.tsx +0 -182
  147. package/src/components/hour-band.tsx +0 -146
  148. package/src/components/hour-group.stories.tsx +0 -100
  149. package/src/components/hour-group.tsx +0 -124
  150. package/src/components/timeline-band.stories.tsx +0 -206
  151. package/src/components/timeline-band.tsx +0 -247
@@ -99,6 +99,13 @@ export type MenuFamilyBlockProps = {
99
99
  hue?: string;
100
100
  /** Finished CSS gradient — the family's colour over the mosaic. */
101
101
  gradient?: string;
102
+ /**
103
+ * `wide` (default): the phone card below `lg`, the two-column card — photo
104
+ * third, rows beside it — from `lg`. `compact`: the phone card at every
105
+ * width, for a rail on a desktop page where the two-column card is too much
106
+ * to scan across (the browse page's concept rail).
107
+ */
108
+ variant?: "wide" | "compact";
102
109
  linkComponent?: MenuLinkComponent;
103
110
  className?: string;
104
111
  };
@@ -108,17 +115,20 @@ const GROUND = PHOTO_GROUND;
108
115
 
109
116
  /**
110
117
  * 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
113
- * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
114
- * lives in the app's stylesheet, not this package's, so a DS component reading
115
- * it renders nothing in Storybook and the other three consumers.
118
+ * top 40% is the photograph, the rows sit on 0.92+. It is the layer the
119
+ * contrast rests on. Literal rather than `var(--scrim)`: that token lives in
120
+ * the app's stylesheet, not this package's, so a DS component reading it
121
+ * renders nothing in Storybook and the other three consumers.
116
122
  */
117
- const INK =
118
- "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
119
-
120
- /** The row column's paper from `lg`: the card's dark, opaque. */
121
- const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
123
+ const INK_PHONE =
124
+ "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%)]";
125
+ /**
126
+ * From `lg` the ramp runs to the right: clear over the photo third, dark
127
+ * under the rows — the rows sit on the ink itself, no opaque paper, so the
128
+ * photograph still reads through the ramp's shoulder.
129
+ */
130
+ const INK_WIDE =
131
+ "lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.32)_0%,oklch(0.16_0.012_55_/_0.62)_34%,oklch(0.16_0.012_55_/_0.9)_52%,oklch(0.16_0.012_55_/_0.96)_100%)]";
122
132
 
123
133
  /**
124
134
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -144,9 +154,11 @@ export function MenuFamilyBlock({
144
154
  formatCount = String,
145
155
  hue,
146
156
  gradient,
157
+ variant = "wide",
147
158
  linkComponent: LinkComponent = DefaultLink,
148
159
  className,
149
160
  }: MenuFamilyBlockProps) {
161
+ const wide = variant === "wide";
150
162
  const [photo] = family.photoUrls ?? [];
151
163
  // "+N more" counts past the rows shown, which are the venues the reader can
152
164
  // see — the photograph is one of them, not a venue on its own.
@@ -161,7 +173,9 @@ export function MenuFamilyBlock({
161
173
  className={cn(
162
174
  /* `isolate` confines the blend and the backdrop-filter to this card.
163
175
  `dark` scopes every token inside to its dark-theme value. */
164
- "dark relative isolate overflow-hidden rounded-[14px] text-foreground",
176
+ /* A flex column, so a `h-full` card in a rail fills its row: siblings
177
+ with two rows and with three end on the same baseline. */
178
+ "dark relative isolate flex flex-col overflow-hidden rounded-[14px] text-foreground",
165
179
  GROUND,
166
180
  className
167
181
  )}
@@ -171,21 +185,32 @@ export function MenuFamilyBlock({
171
185
  <PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
172
186
 
173
187
  {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
174
- <span aria-hidden className={cn("absolute inset-0", INK)} />
188
+ <span aria-hidden className={cn("absolute inset-0", INK_PHONE, wide && INK_WIDE)} />
175
189
 
176
190
  {/* ── The content, floating ───────────────────────────────────────── */}
177
- <div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
191
+ <div
192
+ className={cn(
193
+ "relative grid flex-1 grid-cols-1 grid-rows-[auto_1fr]",
194
+ wide && "lg:grid-cols-[34%_1fr] lg:grid-rows-1"
195
+ )}
196
+ >
178
197
  {/* The photo column. On the phone it is the card's head, with the
179
198
  overflow pill pinned to the corner and the photograph clear above
180
199
  the name; on desktop the left third, its stack anchored to the foot. */}
181
200
  <div
182
201
  className={cn(
183
- "relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
202
+ "relative flex min-h-[220px] flex-col justify-end p-3.5",
203
+ wide && "lg:min-h-[320px] lg:p-5",
184
204
  TEXT_SCRIM
185
205
  )}
186
206
  >
187
207
  {pill && (
188
- <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">
208
+ <span
209
+ className={cn(
210
+ "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",
211
+ wide && "lg:static lg:mb-3 lg:self-start"
212
+ )}
213
+ >
189
214
  {pill}
190
215
  </span>
191
216
  )}
@@ -195,7 +220,12 @@ export function MenuFamilyBlock({
195
220
  {family.kicker}
196
221
  </span>
197
222
  )}
198
- <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
223
+ <h3
224
+ className={cn(
225
+ "text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white",
226
+ wide && "lg:text-[30px]"
227
+ )}
228
+ >
199
229
  {family.name}
200
230
  </h3>
201
231
  {/* The count, the facts and the band on one line: a few facts about
@@ -230,7 +260,7 @@ export function MenuFamilyBlock({
230
260
  aria-label={labels.browse}
231
261
  className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
232
262
  >
233
- {labels.browseShort ? (
263
+ {labels.browseShort && wide ? (
234
264
  <>
235
265
  <span className="lg:hidden">{labels.browse}</span>
236
266
  <span className="hidden lg:inline">{labels.browseShort}</span>
@@ -241,10 +271,10 @@ export function MenuFamilyBlock({
241
271
  </LinkComponent>
242
272
  </div>
243
273
 
244
- {/* The row column: chips, then the rows, on paper from `lg`. */}
245
- <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
274
+ {/* The row column: chips, then the rows, on the ink's dark shoulder. */}
275
+ <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5", wide && "lg:p-5")}>
246
276
  {family.chips && family.chips.length > 0 && (
247
- <ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
277
+ <ul className={cn("flex flex-wrap items-center gap-1.5", wide && "lg:justify-end")}>
248
278
  {family.chips.map((chip) => (
249
279
  /* Label only. The counts are VENUES beside a dish group's name,
250
280
  which every reader takes for dishes. */
@@ -261,6 +291,9 @@ export function MenuFamilyBlock({
261
291
  <MenuKitchenList
262
292
  kitchens={venues}
263
293
  labels={labels.kitchens}
294
+ /* A half-width column: the dish goes under the venue, not
295
+ beside it. The compact card is the phone row throughout. */
296
+ layout={wide ? "stacked" : "phone"}
264
297
  linkComponent={LinkComponent}
265
298
  />
266
299
  </>
@@ -59,11 +59,19 @@ export type MenuFilterChipsProps = {
59
59
  onMorePointerDown?: () => void;
60
60
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
61
61
  formatCount?: (value: number) => string;
62
+ /**
63
+ * `sm` (36px) is the row under a page header. `lg` (44px) is for a row
64
+ * inside a sticky bar, where the chips are the only thing the thumb can
65
+ * reach and 36px sits under the target floor.
66
+ */
67
+ chipSize?: MenuFilterChipSize;
62
68
  /** Accessible name for the row. */
63
69
  ariaLabel?: string;
64
70
  className?: string;
65
71
  };
66
72
 
73
+ export type MenuFilterChipSize = "sm" | "lg";
74
+
67
75
  export function MenuFilterChips({
68
76
  chips,
69
77
  onToggle,
@@ -73,6 +81,7 @@ export function MenuFilterChips({
73
81
  moreIcon,
74
82
  onMorePointerDown,
75
83
  formatCount = String,
84
+ chipSize = "sm",
76
85
  ariaLabel,
77
86
  className,
78
87
  }: MenuFilterChipsProps) {
@@ -99,6 +108,7 @@ export function MenuFilterChips({
99
108
  <Chip
100
109
  key={chip.key}
101
110
  chip={chip}
111
+ size={chipSize}
102
112
  onToggle={() => onToggle(chip.key)}
103
113
  formatCount={formatCount}
104
114
  />
@@ -120,7 +130,11 @@ export function MenuFilterChips({
120
130
  onPointerDown={onMorePointerDown}
121
131
  aria-label={moreLabel}
122
132
  aria-haspopup="dialog"
123
- className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden"
133
+ className={cn(
134
+ "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
135
+ // The one control that never scrolls keeps the chips' height.
136
+ chipSize === "lg" ? "size-11" : "size-10"
137
+ )}
124
138
  >
125
139
  {moreIcon}
126
140
  </button>
@@ -143,10 +157,12 @@ export function MenuFilterChips({
143
157
 
144
158
  function Chip({
145
159
  chip,
160
+ size,
146
161
  onToggle,
147
162
  formatCount,
148
163
  }: {
149
164
  chip: MenuFilterChip;
165
+ size: MenuFilterChipSize;
150
166
  onToggle: () => void;
151
167
  formatCount: (value: number) => string;
152
168
  }) {
@@ -156,7 +172,8 @@ function Chip({
156
172
  onClick={onToggle}
157
173
  aria-pressed={chip.active}
158
174
  className={cn(
159
- "inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
175
+ "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
176
+ size === "lg" ? "h-11" : "h-9",
160
177
  chip.active
161
178
  ? "border-accent font-semibold text-accent shadow-[0_1px_2px_oklch(0.51_0.095_45_/_0.08)]"
162
179
  : "border-border bg-card text-foreground hover:border-accent/40",
@@ -226,12 +243,23 @@ export function MenuFilterSummary({
226
243
  */
227
244
  const CHIP_WIDTHS = [128, 104, 96, 148, 112, 88];
228
245
 
229
- export function MenuFilterChipsSkeleton({ className }: { className?: string }) {
246
+ export function MenuFilterChipsSkeleton({
247
+ className,
248
+ chipSize = "sm",
249
+ }: {
250
+ className?: string;
251
+ /** Match the row it stands in for, so the chips do not jump 8px on load. */
252
+ chipSize?: MenuFilterChipSize;
253
+ }) {
230
254
  return (
231
255
  <div className={cn("flex items-center gap-2 overflow-hidden", className)} aria-hidden>
232
256
  <Skeleton className="h-3 w-[92px] shrink-0" />
233
257
  {CHIP_WIDTHS.map((width, index) => (
234
- <Skeleton key={index} className="h-9 shrink-0 rounded-full" style={{ width }} />
258
+ <Skeleton
259
+ key={index}
260
+ className={cn("shrink-0 rounded-full", chipSize === "lg" ? "h-11" : "h-9")}
261
+ style={{ width }}
262
+ />
235
263
  ))}
236
264
  </div>
237
265
  );
@@ -28,6 +28,19 @@ describe("MenuHubHero mosaic", () => {
28
28
  });
29
29
  });
30
30
 
31
+ describe("MenuHubHero heading level", () => {
32
+ it("renders the headline as h1 by default", () => {
33
+ render(<MenuHubHero headline="Every kitchen" />);
34
+ expect(document.querySelector("h1")?.textContent).toBe("Every kitchen");
35
+ });
36
+
37
+ it("renders as h2 for a page that already owns its h1", () => {
38
+ render(<MenuHubHero headline="Every kitchen" headingLevel="h2" />);
39
+ expect(document.querySelector("h1")).toBeNull();
40
+ expect(document.querySelector("h2")?.textContent).toBe("Every kitchen");
41
+ });
42
+ });
43
+
31
44
  describe("VenueWall mosaic", () => {
32
45
  it("follows the hero's rule: six on the phone, eighteen from lg", () => {
33
46
  render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
@@ -60,6 +60,12 @@ export type MenuHubHeroProps = {
60
60
  * a 1440px hero.
61
61
  */
62
62
  tileUrls?: string[];
63
+ /**
64
+ * The element the headline renders as. `h1` by default; a page that
65
+ * already owns its `<h1>` (an sr-only SEO heading, as on `/menu`) passes
66
+ * `h2` so the document has one h1, not two.
67
+ */
68
+ headingLevel?: "h1" | "h2";
63
69
  className?: string;
64
70
  };
65
71
 
@@ -84,6 +90,7 @@ export function MenuHubHero({
84
90
  action,
85
91
  backgroundUrl,
86
92
  tileUrls,
93
+ headingLevel: Heading = "h1",
87
94
  className,
88
95
  }: MenuHubHeroProps) {
89
96
  return (
@@ -160,9 +167,9 @@ export function MenuHubHero({
160
167
 
161
168
  {/* 28px on a phone: 32px on a 358px column is five or six words a
162
169
  line, which left "walk in blind." wrapping alone. */}
163
- <h1 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
170
+ <Heading className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.06] tracking-[-0.025em] lg:text-[50px] lg:leading-[1.04]">
164
171
  {headline}
165
- </h1>
172
+ </Heading>
166
173
 
167
174
  {body && (
168
175
  <p className="hidden max-w-[540px] text-sm leading-[1.6] opacity-[0.84] lg:block lg:text-[15.5px]">
@@ -61,6 +61,18 @@ export type MenuKitchenListLabels = {
61
61
  showMore?: string;
62
62
  };
63
63
 
64
+ /**
65
+ * `wide` (default): the intent page's full-width list — from `lg` the venue
66
+ * takes a fixed 226px column and the dish stands beside it in a column of its
67
+ * own. `stacked`: the list sits in a narrow column (a concept card's row
68
+ * half), so the venue keeps the row and the dish takes a SECOND LINE under it
69
+ * from `lg`; in a half-width card the dish column had ~100px and its kicker,
70
+ * name and meta wrapped into a stack of single words. `phone`: the phone row
71
+ * at every width — no dish line, the venue and its price only — for a card
72
+ * that keeps its phone form on a desktop rail.
73
+ */
74
+ export type MenuKitchenListLayout = "wide" | "stacked" | "phone";
75
+
64
76
  export type MenuKitchenListProps = {
65
77
  kitchens: MenuKitchenPreview[];
66
78
  labels: MenuKitchenListLabels;
@@ -69,6 +81,7 @@ export type MenuKitchenListProps = {
69
81
  * navigates for the eleventh — the list is the page.
70
82
  */
71
83
  onShowMore?: () => void;
84
+ layout?: MenuKitchenListLayout;
72
85
  linkComponent?: MenuLinkComponent;
73
86
  className?: string;
74
87
  };
@@ -77,6 +90,7 @@ export function MenuKitchenList({
77
90
  kitchens,
78
91
  labels,
79
92
  onShowMore,
93
+ layout = "wide",
80
94
  linkComponent = DefaultLink,
81
95
  className,
82
96
  }: MenuKitchenListProps) {
@@ -90,7 +104,12 @@ export function MenuKitchenList({
90
104
  because `<ol>` admits only list items, so a sibling element
91
105
  between them would be invalid markup. */}
92
106
  {index > 0 && <Separator />}
93
- <KitchenRow kitchen={kitchen} labels={labels} linkComponent={linkComponent} />
107
+ <KitchenRow
108
+ kitchen={kitchen}
109
+ labels={labels}
110
+ layout={layout}
111
+ linkComponent={linkComponent}
112
+ />
94
113
  </li>
95
114
  ))}
96
115
  </ol>
@@ -111,17 +130,25 @@ export function MenuKitchenList({
111
130
  function KitchenRow({
112
131
  kitchen,
113
132
  labels,
133
+ layout,
114
134
  linkComponent: LinkComponent,
115
135
  }: {
116
136
  kitchen: MenuKitchenPreview;
117
137
  labels: MenuKitchenListLabels;
138
+ layout: MenuKitchenListLayout;
118
139
  linkComponent: MenuLinkComponent;
119
140
  }) {
120
141
  const { place, dish } = kitchen;
121
142
  const openNow = place.open_now === true;
143
+ const wide = layout === "wide";
122
144
 
123
145
  return (
124
- <div className="relative flex flex-wrap items-center gap-[11px] py-3 transition-colors hover:bg-muted/30 motion-reduce:transition-none lg:flex-nowrap lg:gap-4 lg:py-3.5">
146
+ <div
147
+ className={cn(
148
+ "relative flex flex-wrap items-center gap-[11px] py-3 transition-colors hover:bg-muted/30 motion-reduce:transition-none lg:gap-4 lg:py-3.5",
149
+ wide && "lg:flex-nowrap"
150
+ )}
151
+ >
125
152
  {/* The venue link, stretched. `z-[1]` — the dish link sits above it. */}
126
153
  <LinkComponent
127
154
  href={kitchen.href}
@@ -149,7 +176,12 @@ function KitchenRow({
149
176
  />
150
177
  </span>
151
178
 
152
- <div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:w-[226px] lg:flex-none">
179
+ <div
180
+ className={cn(
181
+ "flex min-w-0 flex-1 flex-col gap-0.5",
182
+ wide && "lg:w-[226px] lg:flex-none"
183
+ )}
184
+ >
153
185
  {/* One line with an ellipsis on a phone, where the row is the whole
154
186
  width; two lines from `lg`, where the column is 226px and
155
187
  "ADRIATICA GELATERIA ITALIANA" would otherwise lose its half. */}
@@ -190,8 +222,15 @@ function KitchenRow({
190
222
  one tap away on the venue's own page, with room to state it.
191
223
 
192
224
  `order-5` puts it last in the visual order without moving it in the
193
- DOM, where it belongs to the venue. */
194
- <div className="hidden min-w-0 basis-full pl-[57px] lg:order-none lg:block lg:flex-1 lg:basis-auto lg:pl-0">
225
+ DOM, where it belongs to the venue. In the `stacked` layout it is a
226
+ second LINE under the venue, indented past the thumbnail. */
227
+ <div
228
+ className={cn(
229
+ "hidden min-w-0 basis-full pl-[57px]",
230
+ layout !== "phone" && "lg:block",
231
+ wide ? "lg:order-none lg:flex-1 lg:basis-auto lg:pl-0" : "lg:order-5 lg:pl-[74px]"
232
+ )}
233
+ >
195
234
  <span
196
235
  className={cn(
197
236
  "flex items-center gap-1.5 text-[9px] font-extrabold uppercase tracking-[0.1em]",
@@ -230,7 +269,7 @@ function KitchenRow({
230
269
  </div>
231
270
  )}
232
271
 
233
- <div className="ml-auto flex shrink-0 items-center gap-[11px] lg:ml-0">
272
+ <div className={cn("ml-auto flex shrink-0 items-center gap-[11px]", wide && "lg:ml-0")}>
234
273
  {kitchen.priceLabel && (
235
274
  <span className="text-base font-extrabold tabular-nums text-accent">
236
275
  {kitchen.priceLabel}
@@ -38,6 +38,13 @@ import { cn } from "../lib/utils";
38
38
  /** The ground under the photography. Fixed, and deliberately not a token. */
39
39
  export const PHOTO_GROUND = "bg-[oklch(0.21_0.012_55)]";
40
40
 
41
+ /**
42
+ * The shadow a glyph wears when it stands on `PHOTO_GROUND` in a photograph's
43
+ * place — the card family's fallback treatment: a ground and a lifted glyph,
44
+ * never a bare icon on a flat fill. Same literal family as the ground.
45
+ */
46
+ export const PHOTO_GLYPH_SHADOW = "drop-shadow-[0_2px_6px_oklch(0.16_0.012_55_/_0.6)]";
47
+
41
48
  /** The uniform dark overlay a card may put over every photograph. */
42
49
  const OVERLAY = "absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.5)]";
43
50
 
@@ -56,7 +56,7 @@ export const TwoPosters: Story = {
56
56
  },
57
57
  };
58
58
 
59
- /** No `src` renders the neutral placeholder block, same as `EventCard`. */
59
+ /** No `src` renders the poster fallback, same as `EventCard`. */
60
60
  export const MissingSources: Story = {
61
61
  args: {
62
62
  posters: [
@@ -66,3 +66,45 @@ export const MissingSources: Story = {
66
66
  ],
67
67
  },
68
68
  };
69
+
70
+ /**
71
+ * Tappable: every poster an anchor to its event, the title captioned at its
72
+ * foot. Hover or focus a poster and it comes to the front; the press scale
73
+ * is on the anchor, the float on the wrapper, so the two never fight.
74
+ */
75
+ export const Tappable: Story = {
76
+ args: {
77
+ posters: [
78
+ {
79
+ src: posterDataUri("1", "#7a5c46"),
80
+ alt: "Psihotrop poster",
81
+ caption: "Psihotrop — live at Form Space",
82
+ href: "#psihotrop",
83
+ },
84
+ {
85
+ src: posterDataUri("2", "#4a6a5c"),
86
+ alt: "Warehouse rave poster",
87
+ caption: "Warehouse rave: all night long",
88
+ href: "#warehouse",
89
+ },
90
+ {
91
+ src: posterDataUri("3", "#6a5c8a"),
92
+ alt: "Jazz night poster",
93
+ caption: "Jazz night at the Jazz Club",
94
+ href: "#jazz",
95
+ },
96
+ ],
97
+ },
98
+ };
99
+
100
+ /** The phone geometry: three posters inside a 343px column, none past the gutter. */
101
+ export const Phone: Story = {
102
+ ...Tappable,
103
+ decorators: [
104
+ (Story) => (
105
+ <div className="w-[343px] border-x border-dashed border-border">
106
+ <Story />
107
+ </div>
108
+ ),
109
+ ],
110
+ };