@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
@@ -8,7 +8,7 @@ const labels = {
8
8
  browse: "Browse Italian",
9
9
  };
10
10
 
11
- /** A stand-in photograph with enough variance that the frost has something to soften. */
11
+ /** A stand-in photograph with enough variance that the ink has something to sit on. */
12
12
  function photo(hue: number, label: string) {
13
13
  return (
14
14
  "data:image/svg+xml;utf8," +
@@ -35,7 +35,7 @@ const meta: Meta<typeof MenuFamilyCard> = {
35
35
  docs: {
36
36
  description: {
37
37
  component:
38
- "The hub's doorway to a family — a cuisine on the dishes tab, a drink family on the drinks tab. The same photo language as `MenuFamilyBlock` (the mosaic is the ground, wearing the family's hue; a masked frost and an ink gradient carry the type) drawn one step earlier, in a row of three, where the reader is choosing a family and not yet a venue: one number, one name, the groups the family consists of, and the `Browse` pill. It lists no venue. The count is VENUES: cuisine exists only on places, so a card counting dishes cannot be built. The whole card is one link.",
38
+ "The hub's doorway to a family — a cuisine on the dishes tab, a drink family on the drinks tab. The same photo language as `MenuFamilyBlock` (the mosaic is the ground, wearing the family's hue; an ink gradient carries the type) drawn one step earlier, in a row of three, where the reader is choosing a family and not yet a venue: one number, one name, the groups the family consists of, and the `Browse` pill. It lists no venue. The count is VENUES: cuisine exists only on places, so a card counting dishes cannot be built. The whole card is one link.",
39
39
  },
40
40
  },
41
41
  },
@@ -83,13 +83,6 @@ const MOSAIC_CELLS = 4;
83
83
  /** The card's ground is `PhotoGround`'s, so the two cannot differ. */
84
84
  const GROUND = PHOTO_GROUND;
85
85
 
86
- /**
87
- * The frost: absent over the top half, full at the foot, where the chips and
88
- * the pill sit. The mask is what makes it a gradient of softness rather than
89
- * a frosted pane — the rooms stay sharp behind the number.
90
- */
91
- const FROST = "backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_28%,black_58%)]";
92
-
93
86
  /**
94
87
  * The ink. The count and the name sit at opposite ends of the card, so both
95
88
  * ends are dark — a gradient that cleared in the middle looked better empty
@@ -137,10 +130,10 @@ export function MenuFamilyCard({
137
130
  gradient={gradient}
138
131
  />
139
132
 
140
- {/* ── Layer 2: the frost ──────────────────────────────────────────── */}
141
- <span aria-hidden className={cn("absolute inset-0 -z-20", FROST)} />
142
-
143
- {/* ── Layer 3: the ink ────────────────────────────────────────────── */}
133
+ {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
134
+ {/* There was a masked backdrop-blur under this. Removed for the same
135
+ measurement that took it out of `GlassGround`: the type rests on the
136
+ ink, and the rooms stay sharp all the way down. */}
144
137
  <span aria-hidden className={cn("absolute inset-0 -z-10", INK)} />
145
138
 
146
139
  {/* ── The content, floating ───────────────────────────────────────── */}
@@ -179,10 +172,10 @@ export function MenuFamilyCard({
179
172
  </span>
180
173
  )}
181
174
 
182
- {/* The action, as a frosted pill on the photography — the block's
175
+ {/* The action, as a translucent pill on the photography — the block's
183
176
  pill, so the two cards read as one family. A `span`, because the
184
177
  card is already the link. */}
185
- <span className="mt-3.5 inline-flex h-10 items-center rounded-full border border-white/20 bg-white/[0.12] px-4 text-[13.5px] font-bold text-white backdrop-blur-md transition-colors group-hover:bg-white/20 motion-reduce:transition-none">
178
+ <span className="mt-3.5 inline-flex h-10 items-center rounded-full border border-white/20 bg-white/[0.16] px-4 text-[13.5px] font-bold text-white transition-colors group-hover:bg-white/25 motion-reduce:transition-none">
186
179
  {labels.browse}
187
180
  </span>
188
181
  </span>
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { MenuHubHero } from "./menu-hub-hero";
4
+ import { VenueWall } from "./venue-wall";
5
+
6
+ const tiles = Array.from({ length: 12 }, (_, i) => `/tiles/${i}.webp`);
7
+ const mosaicImages = () => Array.from(document.querySelectorAll("img[loading='lazy']"));
8
+
9
+ // The phone shows six tiles; the rest are `hidden` below `lg` so a lazy
10
+ // still past the sixth is never requested. This is the request-count win
11
+ // measured on the consumer's menu page — pinned so a cell-count edit cannot
12
+ // silently put all twelve stills back on the phone's critical path.
13
+ describe("MenuHubHero mosaic", () => {
14
+ it("shows the first six tiles on the phone and hides the rest until lg", () => {
15
+ render(<MenuHubHero headline="Every kitchen" tileUrls={tiles} />);
16
+ const images = mosaicImages();
17
+ expect(images).toHaveLength(28);
18
+ images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
19
+ images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
20
+ });
21
+
22
+ it("cycles the tile list so the phone's six are the first six given", () => {
23
+ render(<MenuHubHero headline="Every kitchen" tileUrls={tiles.slice(0, 4)} />);
24
+ const srcs = mosaicImages()
25
+ .slice(0, 6)
26
+ .map((img) => img.getAttribute("src"));
27
+ expect(srcs).toEqual(["/tiles/0.webp", "/tiles/1.webp", "/tiles/2.webp", "/tiles/3.webp", "/tiles/0.webp", "/tiles/1.webp"]);
28
+ });
29
+ });
30
+
31
+ describe("VenueWall mosaic", () => {
32
+ it("follows the hero's rule: six on the phone, eighteen from lg", () => {
33
+ render(<VenueWall places={[]} headline="Every menu" tileUrls={tiles} />);
34
+ const images = mosaicImages();
35
+ expect(images).toHaveLength(18);
36
+ images.slice(0, 6).forEach((img) => expect(img).not.toHaveClass("hidden"));
37
+ images.slice(6).forEach((img) => expect(img).toHaveClass("hidden", "lg:block"));
38
+ });
39
+ });
@@ -65,6 +65,13 @@ export type MenuHubHeroProps = {
65
65
 
66
66
  /** Enough cells to fill the widest hero without the grid reading as a pattern. */
67
67
  const TEXTURE_CELLS = 28;
68
+ /**
69
+ * The phone shows the first six, three across. Twenty-eight cells four across
70
+ * on a 375px hero were 90px tiles under a 90% scrim — a texture nobody could
71
+ * read as a room — and every one of the twelve stills downloaded to draw it.
72
+ * Six at ~125px are rooms again and cost six requests.
73
+ */
74
+ const PHONE_CELLS = 6;
68
75
 
69
76
  export function MenuHubHero({
70
77
  kicker,
@@ -97,10 +104,12 @@ export function MenuHubHero({
97
104
  ) : (
98
105
  <div
99
106
  aria-hidden
100
- className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-7"
107
+ className="absolute inset-0 grid auto-rows-fr grid-cols-3 gap-0.5 lg:grid-cols-7"
101
108
  >
102
109
  {Array.from({ length: TEXTURE_CELLS }, (_, cell) => {
103
110
  const url = tileUrls?.length ? tileUrls[cell % tileUrls.length] : undefined;
111
+ // `hidden` keeps a lazy image from ever being requested on the phone.
112
+ const phone = cell < PHONE_CELLS ? undefined : "hidden lg:block";
104
113
 
105
114
  return url ? (
106
115
  // eslint-disable-next-line @next/next/no-img-element
@@ -110,12 +119,15 @@ export function MenuHubHero({
110
119
  alt=""
111
120
  loading="lazy"
112
121
  decoding="async"
113
- className="h-full w-full object-cover"
122
+ className={cn("h-full w-full object-cover", phone)}
114
123
  />
115
124
  ) : (
116
125
  <div
117
126
  key={cell}
118
- className="bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]"
127
+ className={cn(
128
+ "bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]",
129
+ phone
130
+ )}
119
131
  />
120
132
  );
121
133
  })}
@@ -133,7 +145,7 @@ export function MenuHubHero({
133
145
  className={cn(
134
146
  "absolute inset-0",
135
147
  tileUrls?.length || backgroundUrl
136
- ? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.88)_52%,oklch(0.16_0.012_55_/_0.72)_100%)]"
148
+ ? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.86)_52%,oklch(0.16_0.012_55_/_0.6)_100%)]"
137
149
  : "bg-[linear-gradient(100deg,oklch(0.18_0.012_55_/_0.95)_0%,oklch(0.18_0.012_55_/_0.72)_52%,oklch(0.18_0.012_55_/_0.3)_100%)]"
138
150
  )}
139
151
  />
@@ -146,7 +158,9 @@ export function MenuHubHero({
146
158
  </span>
147
159
  )}
148
160
 
149
- <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]">
150
164
  {headline}
151
165
  </h1>
152
166
 
@@ -157,23 +171,27 @@ export function MenuHubHero({
157
171
  )}
158
172
 
159
173
  {figures && figures.length > 0 && (
160
- // Two by two on the phone: four figures in one row wrapped to three and an orphan.
161
- <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">
162
178
  {figures.map((figure) => (
163
- <div key={figure.label}>
164
- <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]">
165
181
  {figure.value}
166
182
  </div>
167
- <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>
168
186
  </div>
169
187
  ))}
170
188
  </div>
171
189
  )}
172
190
 
173
191
  {(proof || action) && (
174
- <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">
175
193
  {proof && (
176
- <span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/10 px-4 py-2 text-[13px] backdrop-blur-[6px]">
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]">
177
195
  {proofIcon}
178
196
  {proof}
179
197
  </span>
@@ -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">