@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,5 +1,6 @@
1
1
  import { Wallet } from "lucide-react";
2
2
  import { cn } from "../lib/utils";
3
+ import { buttonVariants } from "./button";
3
4
  import { HueChip } from "./hue-chip";
4
5
  import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
5
6
  import { Separator } from "./separator";
@@ -12,15 +13,16 @@ import type {
12
13
  } from "./menu-types";
13
14
 
14
15
  /**
15
- * MenuFamilyBlock — the cuisines index's family card. The mosaic is the ground.
16
+ * MenuFamilyBlock — the cuisines index's family card. One photograph is the ground.
16
17
  *
17
18
  * ## Three layers, and everything floats on them
18
19
  *
19
- * The grid of venue photographs is the background for the ENTIRE card, edge to
20
- * edge, and the facts sit on it. Three layers do the work:
20
+ * One venue photograph is the background for the ENTIRE card, edge to edge,
21
+ * and the facts sit on it. Three layers do the work:
21
22
  *
22
- * 1. **The mosaic**, full-bleed, wearing the family's hue through a
23
- * `mix-blend-color` layer.
23
+ * 1. **The photograph**, full-bleed, wearing the family's hue through a
24
+ * `mix-blend-color` layer. It was a mosaic of six; the design draws one
25
+ * plate, and six tiles under the ink read as texture rather than a room.
24
26
  * 2. **An ink gradient** — absent where the photography is the subject, full
25
27
  * where the rows begin. This is what buys the contrast. There used to be a
26
28
  * masked backdrop-filter under it; it was removed for the measurement
@@ -29,10 +31,9 @@ import type {
29
31
  *
30
32
  * On the phone the ink runs top to bottom: the name and the action sit on
31
33
  * clear photography, the chips and the rows on the inked foot. On desktop the
32
- * same layers turn ninety degrees the photo column on the left stays clear,
33
- * the row column on the right is the inked one. The mosaic
34
- * runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
35
- * photograph too big to read as texture.
34
+ * photo column on the left (a third) stays clear, and the row column on the
35
+ * right is opaque paper in the card's own dark three venue rows never sit
36
+ * on a photograph.
36
37
  *
37
38
  * ## The dark scope
38
39
  *
@@ -80,6 +81,12 @@ export type MenuFamilyBlockLabels = {
80
81
  priceBand: string;
81
82
  /** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
82
83
  browse: string;
84
+ /**
85
+ * The action from `lg`, where the photo column is a third of the card and
86
+ * "Browse Bakeries & patisseries" does not fit — e.g. "Browse". The full
87
+ * `browse` stays the accessible name.
88
+ */
89
+ browseShort?: string;
83
90
  /** Passed through to the venue rows. */
84
91
  kitchens: MenuKitchenListLabels;
85
92
  };
@@ -96,22 +103,22 @@ export type MenuFamilyBlockProps = {
96
103
  className?: string;
97
104
  };
98
105
 
99
- /** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
100
- const MOSAIC_TILES = 6;
101
- const PHONE_TILES = 4;
102
-
103
106
  /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
104
107
  const GROUND = PHOTO_GROUND;
105
108
 
106
109
  /**
107
- * The ink. Absent at the top, heavier at the foot on the phone; absent on the
108
- * left, full on the right from `lg`. It is the
110
+ * The ink. Nearly absent at the top, heavier at the foot on the phone the
111
+ * top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
112
+ * `lg`, where the row column brings its own opaque paper anyway. It is the
109
113
  * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
110
114
  * lives in the app's stylesheet, not this package's, so a DS component reading
111
115
  * it renders nothing in Storybook and the other three consumers.
112
116
  */
113
117
  const INK =
114
- "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.42)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.9)_48%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
118
+ "bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
119
+
120
+ /** The row column's paper from `lg`: the card's dark, opaque. */
121
+ const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
115
122
 
116
123
  /**
117
124
  * A second, LOCAL scrim under the name, count and action — the photo column's
@@ -140,10 +147,12 @@ export function MenuFamilyBlock({
140
147
  linkComponent: LinkComponent = DefaultLink,
141
148
  className,
142
149
  }: MenuFamilyBlockProps) {
143
- const photos = (family.photoUrls ?? []).slice(0, MOSAIC_TILES);
144
- const overflow = Math.max(family.venueCount - photos.length, 0);
145
- const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
150
+ const [photo] = family.photoUrls ?? [];
151
+ // "+N more" counts past the rows shown, which are the venues the reader can
152
+ // see the photograph is one of them, not a venue on its own.
146
153
  const venues = family.venues ?? [];
154
+ const overflow = Math.max(family.venueCount - venues.length, 0);
155
+ const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
147
156
 
148
157
  return (
149
158
  <article
@@ -155,30 +164,26 @@ export function MenuFamilyBlock({
155
164
  className
156
165
  )}
157
166
  >
158
- {/* ── Layer 1: the photographs, wearing the family's colour ──────── */}
159
- {/* A 2×2 on the phone, 3×2 from `lg`: at 1110px a four-tile grid makes
160
- each photograph too big to read as texture. With no photographs at
161
- all, one slot with the family's initial. */}
162
- <PhotoGround
163
- photos={photos.length > 0 ? photos : [undefined]}
164
- initials={initial}
165
- mosaicClassName={
166
- photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
167
- }
168
- phoneTiles={PHONE_TILES}
169
- gradient={gradient}
170
- />
167
+ {/* ── Layer 1: the photograph, wearing the family's colour ───────── */}
168
+ {/* One venue's photograph, or the family's initial on the ground. */}
169
+ <PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
171
170
 
172
171
  {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
173
172
  <span aria-hidden className={cn("absolute inset-0", INK)} />
174
173
 
175
174
  {/* ── The content, floating ───────────────────────────────────────── */}
176
- <div className="relative grid grid-cols-1 lg:grid-cols-[38%_1fr]">
177
- {/* The photo column. On the phone it is the card's head; on desktop
178
- the left third, with its stack anchored to the foot. */}
179
- <div className={cn("relative flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5", TEXT_SCRIM)}>
175
+ <div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
176
+ {/* The photo column. On the phone it is the card's head, with the
177
+ overflow pill pinned to the corner and the photograph clear above
178
+ the name; on desktop the left third, its stack anchored to the foot. */}
179
+ <div
180
+ className={cn(
181
+ "relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
182
+ TEXT_SCRIM
183
+ )}
184
+ >
180
185
  {overflow > 0 && (
181
- <span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:mb-3 lg:self-start">
186
+ <span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
182
187
  {labels.more(overflow)}
183
188
  </span>
184
189
  )}
@@ -186,55 +191,57 @@ export function MenuFamilyBlock({
186
191
  <h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
187
192
  {family.name}
188
193
  </h3>
189
- <span className="mt-0.5 block text-[13px] font-medium tabular-nums text-white/70">
194
+ {/* The count and the band on one line: two facts about the family,
195
+ read together — "96 kitchens, 14–46 lei". */}
196
+ <span className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[13px] font-medium tabular-nums text-white/70">
190
197
  {labels.venueUnit(family.venueCount)}
198
+ {family.priceBandLabel && (
199
+ <span
200
+ className="inline-flex items-center gap-1"
201
+ aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
202
+ >
203
+ <Wallet className="h-3.5 w-3.5 shrink-0 text-white/60" aria-hidden />
204
+ {family.priceBandLabel}
205
+ </span>
206
+ )}
191
207
  </span>
192
208
 
193
- {/* The action, as a translucent pill on the photography. `bg-white/16`
194
- on the ink rather than `bg-accent`: ten filled accent buttons on
195
- one page is ten primary actions, and the pill reads as part of the
196
- picture rather than as a control laid on top of it.
197
-
198
- No glyph. An arrow read as "leaves the site" beside a label that
199
- already says where it goes, and a chevron would have made the pill
200
- one more row: every venue row to its right ends in exactly that
201
- chevron. The shape and the label are the whole affordance. */}
209
+ {/* The hub's one action on ink. No glyph: a chevron would have made
210
+ the pill one more row, and every venue row beside it ends in
211
+ exactly that chevron. */}
202
212
  <LinkComponent
203
213
  href={family.href}
204
- className="mt-3.5 inline-flex h-10 w-max items-center rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white transition-colors hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none"
214
+ aria-label={labels.browse}
215
+ className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
205
216
  >
206
- {labels.browse}
217
+ {labels.browseShort ? (
218
+ <>
219
+ <span className="lg:hidden">{labels.browse}</span>
220
+ <span className="hidden lg:inline">{labels.browseShort}</span>
221
+ </>
222
+ ) : (
223
+ labels.browse
224
+ )}
207
225
  </LinkComponent>
208
226
  </div>
209
227
 
210
- {/* The inked column: chips, then the rows. */}
211
- <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
212
- <ul className="flex flex-wrap items-center gap-1.5">
213
- {family.priceBandLabel && (
214
- /* No hue: a fact about money, not about the family. In the dark
215
- scope `HueChip`'s `--muted` fallback is the dark pill the design
216
- draws. */
217
- <HueChip
218
- as="li"
219
- className="font-bold tabular-nums"
220
- aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
221
- icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
222
- >
223
- {family.priceBandLabel}
224
- </HueChip>
225
- )}
226
- {family.chips?.map((chip) => (
227
- /* Label only. The counts are VENUES beside a dish group's name,
228
- which every reader takes for dishes. */
229
- <HueChip key={chip.slug} as="li" hue={hue}>
230
- {chip.label}
231
- </HueChip>
232
- ))}
233
- </ul>
228
+ {/* The row column: chips, then the rows, on paper from `lg`. */}
229
+ <div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
230
+ {family.chips && family.chips.length > 0 && (
231
+ <ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
232
+ {family.chips.map((chip) => (
233
+ /* Label only. The counts are VENUES beside a dish group's name,
234
+ which every reader takes for dishes. */
235
+ <HueChip key={chip.slug} as="li" hue={hue}>
236
+ {chip.label}
237
+ </HueChip>
238
+ ))}
239
+ </ul>
240
+ )}
234
241
 
235
242
  {venues.length > 0 && (
236
243
  <>
237
- <Separator className="my-3" />
244
+ <Separator className="my-3 via-white/15" />
238
245
  <MenuKitchenList
239
246
  kitchens={venues}
240
247
  labels={labels.kitchens}
@@ -270,12 +277,12 @@ export function MenuFamilyBlockSkeleton({
270
277
  className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
271
278
  aria-hidden
272
279
  >
273
- <div className="grid grid-cols-1 lg:grid-cols-[38%_1fr]">
274
- <div className="flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5">
275
- <Skeleton className="mb-16 h-6 w-[72px] self-end rounded-full lg:mb-3 lg:self-start" />
280
+ <div className="grid grid-cols-1 lg:grid-cols-[34%_1fr]">
281
+ <div className="relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5">
282
+ <Skeleton className="absolute right-3.5 top-3.5 h-6 w-[72px] rounded-full lg:static lg:mb-3" />
276
283
  <Skeleton className="h-7 w-[60%]" />
277
- <Skeleton className="mt-2 h-3.5 w-[90px]" />
278
- <Skeleton className="mt-3.5 h-10 w-[160px] rounded-full" />
284
+ <Skeleton className="mt-2 h-3.5 w-[150px]" />
285
+ <Skeleton className="mt-3.5 h-11 w-[160px] rounded-full" />
279
286
  </div>
280
287
  <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
281
288
  <div className="flex gap-1.5">
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuFamilyTiles } from "./menu-family-tiles";
3
+ import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof MenuFamilyTiles> = {
6
+ title: "Menu/MenuFamilyTiles",
7
+ component: MenuFamilyTiles,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[1100px] bg-background p-4">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The hub's doorways: every family the city browses by, as a grid of coloured tiles — the family's name, its venue count and one venue's photograph tilted off the corner — closed by one tile that opens the whole index. Two to a row on a phone, four from `lg`, so seven families and the index make two rows at every width.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof MenuFamilyTiles>;
28
+
29
+ export const Default: Story = { args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll } };
30
+
31
+ /** Two-up on a phone — seven families and the index in four rows. */
32
+ export const Mobile: Story = {
33
+ args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll },
34
+ // The grid's columns follow the viewport, not the wrapper.
35
+ parameters: { viewport: { defaultViewport: "mobile1" } },
36
+ decorators: [
37
+ (Story) => (
38
+ <div className="w-[390px] bg-background p-3">
39
+ <Story />
40
+ </div>
41
+ ),
42
+ ],
43
+ };
44
+
45
+ /** No photographs anywhere: every polaroid holds the family's initial. */
46
+ export const WithoutPhotos: Story = {
47
+ args: {
48
+ tiles: mockFamilyTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
49
+ all: { ...mockFamilyTilesAll, photoUrl: undefined },
50
+ },
51
+ };
52
+
53
+ /** The drinks tab's five going-out families, no index tile. */
54
+ export const Partial: Story = { args: { tiles: mockFamilyTiles.slice(0, 5) } };
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MenuFamilyTiles } from "./menu-family-tiles";
4
+ import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
5
+
6
+ describe("MenuFamilyTiles", () => {
7
+ it("renders one link per family plus the index tile, in order", () => {
8
+ render(<MenuFamilyTiles tiles={mockFamilyTiles} all={mockFamilyTilesAll} />);
9
+ const links = screen.getAllByRole("link");
10
+ expect(links).toHaveLength(mockFamilyTiles.length + 1);
11
+ expect(links[0]).toHaveAttribute("href", mockFamilyTiles[0].href);
12
+ expect(links.at(-1)).toHaveAttribute("href", mockFamilyTilesAll.href);
13
+ expect(links.at(-1)).toHaveTextContent(mockFamilyTilesAll.label);
14
+ });
15
+
16
+ // A family without a photograph keeps the polaroid — the initial fills it —
17
+ // so a row of seven never has one tile with a different silhouette.
18
+ it("draws the initial where a family has no photograph", () => {
19
+ const [withPhoto, withoutPhoto] = [mockFamilyTiles[0], mockFamilyTiles.at(-1)!];
20
+ expect(withoutPhoto.photoUrl).toBeUndefined();
21
+ render(<MenuFamilyTiles tiles={[withPhoto, withoutPhoto]} />);
22
+ expect(document.querySelectorAll("img")).toHaveLength(1);
23
+ expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
24
+ });
25
+
26
+ it("omits the index tile when no index is given", () => {
27
+ render(<MenuFamilyTiles tiles={mockFamilyTiles.slice(0, 3)} />);
28
+ expect(screen.getAllByRole("link")).toHaveLength(3);
29
+ });
30
+ });
@@ -0,0 +1,132 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { MenuFamilyTile, MenuLinkComponent, MenuLinkProps } from "./menu-types";
3
+ import { PhotoGround } from "./photo-ground";
4
+
5
+ /**
6
+ * MenuFamilyTiles — the hub's doorways: every family the city browses by, as
7
+ * a grid of coloured tiles, closed by one tile that opens the whole index.
8
+ *
9
+ * ## A tile, not a card
10
+ *
11
+ * The family card this replaces was a full-width mosaic with chips, a price
12
+ * band and a browse pill — a summary of the family, in a rail the reader had
13
+ * to swipe through one family at a time. The tile is the family's NAME, its
14
+ * venue count and one photograph on the family's hue, two to a row, so seven
15
+ * families and the index fit on one phone screen. The reader is choosing a
16
+ * family here, not learning about one; the summary lives on the page the tile
17
+ * opens.
18
+ *
19
+ * ## The photograph is a polaroid, not a ground
20
+ *
21
+ * One square photograph, tilted, running off the tile's top and right edges
22
+ * — the tile's `overflow-hidden` crops it, so it reads as a print dropped on
23
+ * the tile rather than a thumbnail placed in it — with the name and the count
24
+ * in the bottom-left, under the print's lower edge. Not a ground: white type
25
+ * on a photograph needs a scrim heavy enough to hide the photograph, and the
26
+ * hue is what tells seven tiles apart at a glance. The photograph is the
27
+ * FAMILY's — a plate, a room, a glass — served from the consumer's own origin,
28
+ * never a venue's (a venue on a tile about every venue reads as an
29
+ * endorsement). A family with no photograph gets the same tilted square
30
+ * holding `PhotoGround`'s serif initial, so the silhouette never changes.
31
+ *
32
+ * Every measure on the polaroid is a percentage of the tile, so the tile and
33
+ * the design stay in proportion from a 170px phone cell to a 260px desktop
34
+ * one: 82% of the height, 8% above the top, 4% past the right, 8° over —
35
+ * measured off the design frame.
36
+ *
37
+ * The bottom scrim is mandatory. The honey and terrace hues sit around
38
+ * `oklch(0.6…)`, where white at 14.5px is about 3:1 — under the 4.5:1 the
39
+ * label needs. The scrim takes the label's corner down to where it passes on
40
+ * every hue in the dictionary.
41
+ *
42
+ * Server-safe.
43
+ */
44
+
45
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
46
+ return <a href={href} {...props} />;
47
+ }
48
+
49
+ export type MenuFamilyTilesProps = {
50
+ tiles: MenuFamilyTile[];
51
+ /**
52
+ * The closing tile: the index behind the seven, drawn exactly like them —
53
+ * its own hue and photograph, `countLabel` as the caption. Only its place in
54
+ * the grid says it is the index. Omit when there is none.
55
+ */
56
+ all?: MenuFamilyTile;
57
+ linkComponent?: MenuLinkComponent;
58
+ className?: string;
59
+ };
60
+
61
+ /**
62
+ * The tile: the design's 16:10, never shorter than two lines of name plus the
63
+ * count need.
64
+ */
65
+ const TILE =
66
+ "group relative isolate flex aspect-[16/10] min-h-[108px] flex-col justify-end overflow-hidden rounded-2xl bg-muted p-3.5 text-white lg:p-4 " +
67
+ "transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card-hover)] active:translate-y-0 active:scale-[0.99] motion-reduce:transition-none " +
68
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
69
+
70
+ /**
71
+ * The polaroid. Same literal family as `PHOTO_GROUND` for the shadow, never a
72
+ * black `rgba` — the shadow is the ground the card family already stands on.
73
+ */
74
+ const POLAROID =
75
+ "absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] " +
76
+ "shadow-[0_10px_22px_-6px_oklch(0.16_0.012_55_/_0.65)] transition-transform duration-300 group-hover:rotate-[5deg] motion-reduce:transition-none";
77
+
78
+ const SCRIM =
79
+ "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/5 after:bg-gradient-to-t after:from-[oklch(0.16_0.012_55_/_0.55)] after:to-transparent after:content-['']";
80
+
81
+ export function MenuFamilyTiles({
82
+ tiles,
83
+ all,
84
+ linkComponent: LinkComponent = DefaultLink,
85
+ className,
86
+ }: MenuFamilyTilesProps) {
87
+ const cells = all ? [...tiles, all] : tiles;
88
+
89
+ return (
90
+ <div className={cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className)}>
91
+ {cells.map((tile) => (
92
+ <LinkComponent
93
+ key={tile.slug}
94
+ href={tile.href}
95
+ className={cn(TILE, SCRIM)}
96
+ style={tile.gradient ? { backgroundImage: tile.gradient } : undefined}
97
+ >
98
+ <span aria-hidden className={POLAROID}>
99
+ {tile.photoUrl ? (
100
+ /* Not lazy: the grid sits directly under the hero, and these
101
+ photographs are the first thing the reader looks at. */
102
+ // eslint-disable-next-line @next/next/no-img-element
103
+ <img
104
+ src={tile.photoUrl}
105
+ alt=""
106
+ decoding="async"
107
+ className="absolute inset-0 h-full w-full object-cover"
108
+ />
109
+ ) : (
110
+ <PhotoGround
111
+ photos={[undefined]}
112
+ initials={tile.label.charAt(0)}
113
+ mode="stack"
114
+ glyphClassName="text-[44px]"
115
+ />
116
+ )}
117
+ </span>
118
+
119
+ {/* Both lines keep to the left 55%: the polaroid owns the right. */}
120
+ <span className="relative z-[1] line-clamp-2 max-w-[55%] text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]">
121
+ {tile.label}
122
+ </span>
123
+ {tile.countLabel && (
124
+ <span className="relative z-[1] mt-0.5 max-w-[55%] truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]">
125
+ {tile.countLabel}
126
+ </span>
127
+ )}
128
+ </LinkComponent>
129
+ ))}
130
+ </div>
131
+ );
132
+ }
@@ -158,7 +158,9 @@ export function MenuHubHero({
158
158
  </span>
159
159
  )}
160
160
 
161
- <h1 className="max-w-[640px] font-serif text-[32px] font-semibold leading-[1.04] tracking-[-0.025em] lg:text-[50px]">
161
+ {/* 28px on a phone: 32px on a 358px column is five or six words a
162
+ 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]">
162
164
  {headline}
163
165
  </h1>
164
166
 
@@ -169,21 +171,25 @@ export function MenuHubHero({
169
171
  )}
170
172
 
171
173
  {figures && figures.length > 0 && (
172
- // Two by two on the phone: four figures in one row wrapped to three and an orphan.
173
- <div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-3 lg:flex lg:flex-wrap lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
174
+ // One row of four on the phone. It was two by two, which was the
175
+ // taller half of a 500px hero; at 20px the widest figure ("19,090")
176
+ // is ~68px and four of them share a 358px column with room.
177
+ <div className="flex justify-between gap-x-3 pt-2 lg:flex-wrap lg:justify-start lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
174
178
  {figures.map((figure) => (
175
- <div key={figure.label}>
176
- <div className="text-2xl font-black leading-none tracking-[-0.03em] tabular-nums lg:text-[32px]">
179
+ <div key={figure.label} className="min-w-0">
180
+ <div className="text-xl font-black leading-none tracking-[-0.02em] tabular-nums lg:text-[32px] lg:tracking-[-0.03em]">
177
181
  {figure.value}
178
182
  </div>
179
- <div className="mt-[5px] text-xs opacity-[0.72]">{figure.label}</div>
183
+ <div className="mt-1 text-[11px] leading-tight opacity-[0.72] lg:mt-[5px] lg:text-xs">
184
+ {figure.label}
185
+ </div>
180
186
  </div>
181
187
  ))}
182
188
  </div>
183
189
  )}
184
190
 
185
191
  {(proof || action) && (
186
- <div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
192
+ <div className="mt-auto flex flex-wrap items-center gap-3 pt-4 lg:pt-5">
187
193
  {proof && (
188
194
  <span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/[0.14] px-4 py-2 text-[13px]">
189
195
  {proofIcon}
@@ -38,8 +38,13 @@ export const Tail: Story = {
38
38
  args: { entries: mockIndexEntries.slice(-8), title: "The tail" },
39
39
  };
40
40
 
41
- /** Two columns, not one: thirty-eight rows in one column is four screens. */
41
+ /** One column with the expander: twelve rows, then "Show all 38 cuisines". */
42
42
  export const Mobile: Story = {
43
+ args: {
44
+ collapsedCount: 12,
45
+ expandLabels: { showAll: "Show all 38 cuisines", showFewer: "Show fewer" },
46
+ },
47
+ parameters: { viewport: { defaultViewport: "mobile1" } },
43
48
  decorators: [
44
49
  (Story) => (
45
50
  <div className="w-[390px] bg-background p-3">