@bearmenu/ui 0.8.10 → 0.8.11

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 (84) 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-H3ZZDX5L.js} +2 -1
  5. package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
  6. package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
  7. package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
  8. package/dist/components/alert-dialog.js +1 -1
  9. package/dist/components/alert.d.ts +2 -2
  10. package/dist/components/avatar.d.ts +3 -3
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/badge.js +1 -1
  13. package/dist/components/button.d.ts +2 -2
  14. package/dist/components/button.js +1 -1
  15. package/dist/components/calendar.js +1 -1
  16. package/dist/components/card.d.ts +4 -4
  17. package/dist/components/carousel.js +1 -1
  18. package/dist/components/combobox.js +1 -1
  19. package/dist/components/command.d.ts +8 -8
  20. package/dist/components/concept-compare-rail.js +1 -1
  21. package/dist/components/concept-compare.js +4 -4
  22. package/dist/components/concept-convergence.js +1 -1
  23. package/dist/components/date-input.d.ts +1 -1
  24. package/dist/components/dialog.d.ts +2 -2
  25. package/dist/components/empty-state.js +1 -1
  26. package/dist/components/event-detail-panel.js +1 -1
  27. package/dist/components/event-list-row-wide.js +1 -1
  28. package/dist/components/filter-category-row.js +1 -1
  29. package/dist/components/glass-ground.d.ts +26 -31
  30. package/dist/components/glass-ground.js +1 -1
  31. package/dist/components/haptic-button.js +1 -1
  32. package/dist/components/input-group.d.ts +2 -2
  33. package/dist/components/input.d.ts +1 -1
  34. package/dist/components/item.d.ts +4 -4
  35. package/dist/components/kbd.d.ts +2 -2
  36. package/dist/components/kitchen-dossier.d.ts +7 -7
  37. package/dist/components/kitchen-dossier.js +6 -7
  38. package/dist/components/link-button.js +1 -1
  39. package/dist/components/menu-family-block.d.ts +9 -10
  40. package/dist/components/menu-family-block.js +2 -4
  41. package/dist/components/menu-family-card.js +1 -3
  42. package/dist/components/menu-hub-hero.js +10 -5
  43. package/dist/components/menu-item.js +3 -3
  44. package/dist/components/multi-select-combobox.js +2 -2
  45. package/dist/components/native-select.d.ts +2 -2
  46. package/dist/components/pagination.js +1 -1
  47. package/dist/components/photo-ground.d.ts +1 -1
  48. package/dist/components/place-details-mobile.js +1 -1
  49. package/dist/components/searchable-select.js +1 -1
  50. package/dist/components/select.d.ts +1 -1
  51. package/dist/components/signature-chip.d.ts +2 -2
  52. package/dist/components/signature-chip.js +2 -2
  53. package/dist/components/sliding-panels.js +1 -1
  54. package/dist/components/snap-rail.d.ts +2 -2
  55. package/dist/components/spinner.d.ts +4 -4
  56. package/dist/components/tag.d.ts +4 -4
  57. package/dist/components/text.d.ts +3 -3
  58. package/dist/components/toggle.d.ts +1 -1
  59. package/dist/components/top-ranking-entry.js +3 -3
  60. package/dist/components/venue-quotes.d.ts +1 -1
  61. package/dist/components/venue-quotes.js +1 -1
  62. package/dist/components/venue-wall.js +5 -3
  63. package/dist/components/weekday-theme-banner.js +1 -1
  64. package/package.json +1 -1
  65. package/src/components/badge.tsx +1 -1
  66. package/src/components/button.tsx +2 -2
  67. package/src/components/concept-compare-rail.tsx +2 -1
  68. package/src/components/concept-compare.stories.tsx +3 -3
  69. package/src/components/concept-compare.tsx +10 -5
  70. package/src/components/glass-ground.tsx +31 -56
  71. package/src/components/kitchen-dossier.stories.tsx +1 -1
  72. package/src/components/kitchen-dossier.tsx +15 -16
  73. package/src/components/menu-family-block.stories.tsx +3 -3
  74. package/src/components/menu-family-block.tsx +16 -27
  75. package/src/components/menu-family-card.stories.tsx +2 -2
  76. package/src/components/menu-family-card.tsx +6 -13
  77. package/src/components/menu-hub-hero.test.tsx +39 -0
  78. package/src/components/menu-hub-hero.tsx +17 -5
  79. package/src/components/photo-ground.tsx +1 -1
  80. package/src/components/signature-chip.tsx +3 -3
  81. package/src/components/venue-quotes.stories.tsx +1 -1
  82. package/src/components/venue-quotes.tsx +4 -2
  83. package/src/components/venue-wall.tsx +9 -4
  84. package/src/components/weekday-theme-banner.tsx +1 -1
@@ -135,11 +135,16 @@ const ROOM_SCRIM =
135
135
  "absolute inset-0 bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0)_30%,oklch(0.16_0.012_55_/_0.55)_58%,oklch(0.16_0.012_55_/_0.9)_100%)]";
136
136
 
137
137
  /**
138
- * The head band's glass. Mask and ink both run the band's full height and go
139
- * to ZERO at its underside on the same curve — a soft edge, not a line.
138
+ * The head band's ink. It runs the band's full height and goes to ZERO at its
139
+ * underside on an eased curve — a soft edge, not a line. Ink only: the
140
+ * backdrop-blur AND the mask that used to sit under it were removed for the
141
+ * measurement recorded in `GlassGround`. Without the mask the gradient alone
142
+ * shapes the fade, so its lower half is darker than before (~0.28 at 75 %
143
+ * where mask×gradient gave ~0.08) — deliberate, that is where the room
144
+ * names begin.
140
145
  */
141
146
  const HEAD_GLASS =
142
- "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg before:[mask-image:linear-gradient(to_bottom,black_35%,transparent_100%)] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.8)_0%,oklch(0.16_0.012_55_/_0.6)_35%,oklch(0.16_0.012_55_/_0.22)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
147
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.86)_0%,oklch(0.16_0.012_55_/_0.7)_35%,oklch(0.16_0.012_55_/_0.28)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
143
148
 
144
149
  /* The foot band's glass lives in `concept-compare-rail.tsx`, with the foot. */
145
150
 
@@ -303,7 +308,7 @@ export function ConceptCompare({
303
308
  "inline-flex h-[22px] w-max items-center gap-1 rounded-full px-2 text-[10px] font-extrabold uppercase tracking-[0.08em]",
304
309
  isCheapest
305
310
  ? "bg-success text-white"
306
- : "bg-white/15 text-white/85 backdrop-blur-[2px]"
311
+ : "bg-white/20 text-white/85"
307
312
  )}
308
313
  >
309
314
  {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
@@ -330,7 +335,7 @@ export function ConceptCompare({
330
335
 
331
336
  {/* The kitchen's own wording — the evidence that these are
332
337
  matched rows rather than one row repeated. */}
333
- <span className="mt-2 block truncate font-serif text-[14px] italic text-white/90">
338
+ <span className="mt-2 block truncate font-serif text-[14px] text-white/90">
334
339
  {room.alias}
335
340
  </span>
336
341
 
@@ -2,47 +2,47 @@ import type { CSSProperties } from "react";
2
2
  import { cn } from "../lib/utils";
3
3
 
4
4
  /**
5
- * GlassGround — a frosted, inked caption rising out of one edge of a
6
- * photograph.
5
+ * GlassGround — an inked caption rising out of one edge of a photograph.
7
6
  *
8
- * Layers over a `PhotoGround`: two masked `backdrop-blur`s (the frost) and
9
- * an ink gradient (the contrast), running in one direction absent where the
10
- * photograph is the subject, solid where the type is. `MenuFamilyBlock` drew
11
- * this first; this is the same idea with the stops as data, so a card can say
12
- * "clear for 128px, then glass" without a sixth copy of the gradient strings.
7
+ * One layer over a `PhotoGround`: an ink gradient running in one direction —
8
+ * absent where the photograph is the subject, solid where the type is.
9
+ * `MenuFamilyBlock` drew this first; this is the same idea with the stops as
10
+ * data, so a card can say "clear for 128px, then ink" without a sixth copy of
11
+ * the gradient string.
13
12
  *
14
- * ## Why two blurs, and why the ramps are eased
13
+ * ## Why there is no blur any more
15
14
  *
16
- * One blur under a linear mask has an edge: the eye reads the line where
17
- * sharp becomes soft, however gently the mask fades it in. Two blurs — a
18
- * light one that starts early and a heavier one that starts late — make the
19
- * softening itself a gradient (the "progressive blur" of a phone's lock
20
- * screen), and every ramp here is an ease-in through five stops rather than
21
- * a straight line, because a straight ramp has a visible crease at each end
22
- * where its slope changes.
15
+ * This used to be two masked `backdrop-blur`s under the ink a progressive
16
+ * frost. Measured on `/menu` in production (2026-09-14): the page carried 73
17
+ * backdrop-filter composited layers, six dossiers' worth of these among them,
18
+ * and the main thread spent 9 ms per scrolled frame re-layerising them
19
+ * (2.7 s over a six-second fling; 56 ms with backdrop-filter disabled). A
20
+ * frost is not worth a page that cannot scroll on a phone, so the caption
21
+ * rests on the ink alone, eased through five stops so the ramp has no crease.
22
+ * See `docs/audits/audit-menu-page-performance-2026-09-14.md` in the app.
23
23
  *
24
24
  * ## Why the stops are custom properties
25
25
  *
26
- * Tailwind emits only the classes it can read verbatim, so the gradients
27
- * cannot be assembled from props into a class. The classes below are literal
28
- * and read `--glass-*` variables; the component writes those inline, having
26
+ * Tailwind emits only the classes it can read verbatim, so the gradient
27
+ * cannot be assembled from props into a class. The class below is literal and
28
+ * reads `--glass-*` variables; the component writes those inline, having
29
29
  * interpolated the quarter stops in JS (`clear` and `full` must share a
30
30
  * unit). Same mechanism as `HueChip`'s hue. The ink's colour is the
31
31
  * package's one scrim literal, `oklch(0.16 0.012 55)`, never `var(--scrim)`.
32
32
  *
33
33
  * ## The ramp ends where the type begins
34
34
  *
35
- * `clear` is where the frost starts, `full` where it and the ink are solid.
36
- * Set `full` to the exact seam the content starts at — a ramp that runs past
37
- * the seam puts the first row of type on half-frost.
35
+ * `clear` is where the ink starts, `full` where it is solid. Set `full` to
36
+ * the exact seam the content starts at — a ramp that runs past the seam puts
37
+ * the first row of type on half-ink.
38
38
  */
39
39
 
40
40
  export type GlassStops = {
41
41
  /** Which way the ink runs — toward the type. */
42
42
  direction: "to bottom" | "to right";
43
- /** Where the frost begins, e.g. `"0px"` or `"24%"`. */
43
+ /** Where the ink begins, e.g. `"0px"` or `"24%"`. */
44
44
  clear: string;
45
- /** Where frost and ink are solid — the seam the content starts at. Same unit as `clear`. */
45
+ /** Where the ink is solid — the seam the content starts at. Same unit as `clear`. */
46
46
  full: string;
47
47
  };
48
48
 
@@ -52,16 +52,11 @@ export type GlassGroundProps = {
52
52
  lgStops?: GlassStops;
53
53
  /**
54
54
  * How dark the ink gets at the far edge, 0–1. `0.96` is a caption the
55
- * photograph is only a texture under; `0.4` is a photograph the caption is
55
+ * photograph is only a texture under; `0.6` is a photograph the caption is
56
56
  * drawn on — the type is lifted, the room stays a room.
57
57
  */
58
58
  peak?: number;
59
- /**
60
- * How much the frost softens the photograph where it is solid. `xl` turns
61
- * a room into colour; `sm` keeps it a room and only takes the edge off the
62
- * detail behind the type.
63
- */
64
- blur?: "sm" | "md" | "lg" | "xl";
59
+ /** Placement only. The ink is a `bg-*` on this same element, so a background here would replace it. */
65
60
  className?: string;
66
61
  };
67
62
 
@@ -70,23 +65,9 @@ export type GlassGroundProps = {
70
65
  const STOPS = [0, 0.25, 0.5, 0.75, 1] as const;
71
66
  const EASE = [0, 0.06, 0.25, 0.56, 1] as const;
72
67
 
73
- /** The light blur starts at once and is solid halfway; the heavy one starts halfway. */
74
- const FROST_SOFT =
75
- "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s0),rgba(0,0,0,0.25)_var(--glass-s1),black_var(--glass-s2))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s0),rgba(0,0,0,0.25)_var(--glass-lg-s1),black_var(--glass-lg-s2))]";
76
- const FROST_HEAVY =
77
- "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s1),rgba(0,0,0,0.25)_var(--glass-s2),rgba(0,0,0,0.6)_var(--glass-s3),black_var(--glass-s4))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s1),rgba(0,0,0,0.25)_var(--glass-lg-s2),rgba(0,0,0,0.6)_var(--glass-lg-s3),black_var(--glass-lg-s4))]";
78
-
79
68
  const INK =
80
69
  "bg-[linear-gradient(var(--glass-dir),oklch(0.16_0.012_55_/_0)_var(--glass-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)] lg:bg-[linear-gradient(var(--glass-lg-dir),oklch(0.16_0.012_55_/_0)_var(--glass-lg-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-lg-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-lg-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-lg-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-lg-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)]";
81
70
 
82
- /** Literal, for the scanner: the light and the heavy blur per strength. */
83
- const BLUR = {
84
- sm: ["backdrop-blur-[2px]", "backdrop-blur-sm"],
85
- md: ["backdrop-blur-sm", "backdrop-blur-md"],
86
- lg: ["backdrop-blur-md", "backdrop-blur-lg"],
87
- xl: ["backdrop-blur-lg", "backdrop-blur-xl"],
88
- } as const;
89
-
90
71
  /** "72px" → [72, "px"]; "24%" → [24, "%"]. */
91
72
  function measure(value: string): [number, string] {
92
73
  const match = value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i);
@@ -101,13 +82,7 @@ function positions(clear: string, full: string): string[] {
101
82
  return STOPS.map((fraction) => `${from + (to - from) * fraction}${unit}`);
102
83
  }
103
84
 
104
- export function GlassGround({
105
- stops,
106
- lgStops = stops,
107
- peak = 0.96,
108
- blur = "xl",
109
- className,
110
- }: GlassGroundProps) {
85
+ export function GlassGround({ stops, lgStops = stops, peak = 0.96, className }: GlassGroundProps) {
111
86
  const base = positions(stops.clear, stops.full);
112
87
  const lg = positions(lgStops.clear, lgStops.full);
113
88
  const style = {
@@ -120,10 +95,10 @@ export function GlassGround({
120
95
  } as CSSProperties;
121
96
 
122
97
  return (
123
- <span aria-hidden className={cn("pointer-events-none absolute inset-0 block", className)} style={style}>
124
- <span className={cn("absolute inset-0", BLUR[blur][0], FROST_SOFT)} />
125
- <span className={cn("absolute inset-0", BLUR[blur][1], FROST_HEAVY)} />
126
- <span className={cn("absolute inset-0", INK)} />
127
- </span>
98
+ <span
99
+ aria-hidden
100
+ className={cn("pointer-events-none absolute inset-0 block", INK, className)}
101
+ style={style}
102
+ />
128
103
  );
129
104
  }
@@ -76,7 +76,7 @@ const meta: Meta<typeof KitchenDossier> = {
76
76
  docs: {
77
77
  description: {
78
78
  component:
79
- "One kitchen, told as a story on its own photograph. The room is clear at the top; a frosted caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
79
+ "One kitchen, told as a story on its own photograph. The room is clear at the top; an inked caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
80
80
  },
81
81
  },
82
82
  },
@@ -20,7 +20,7 @@ import { VenueQuotes } from "./venue-quotes";
20
20
  * The hub used to say this three times: a spotlight with everything the
21
21
  * digest knows, a ranked row with a grade, a card with the signature dish —
22
22
  * and the same kitchen led all three. This is the one card: the venue's
23
- * photograph clear at the top, a frosted caption rising from the foot with
23
+ * photograph clear at the top, an inked caption rising from the foot with
24
24
  * the identity (rating, name, type · area · reviews), and under it a PANEL
25
25
  * that says the three or four things the reviews established one at a time —
26
26
  * the dish it is known for, what people say, the grade — with segments at the
@@ -33,12 +33,12 @@ import { VenueQuotes } from "./venue-quotes";
33
33
  * bands. This is the reverse — the room is the subject and the facts are its
34
34
  * caption — which is `GlassGround` at its lightest and smallest: anchored to
35
35
  * the CAPTION, not the card, so it covers the identity, the panel and the
36
- * action plus a short lead above them and nothing else; a `sm` blur that
37
- * only takes the edge off the detail; ink that never passes 0.45; a text
38
- * shadow under the identity doing the rest. Everything above the caption is
39
- * the photograph, untouched. The panel is its own glass, so its rule reads
40
- * as a panel and not a hole. The same layout at every width; the desktop
41
- * card is wider, not turned on its side.
36
+ * action plus a short lead above them and nothing else; ink that never
37
+ * passes 0.6; a text shadow under the identity doing the rest. Everything
38
+ * above the caption is the photograph, untouched. The panel is a translucent
39
+ * ink pane (no backdrop blur see `GlassGround` for the measurement that
40
+ * removed it), so its rule reads as a panel and not a hole. The same layout
41
+ * at every width; the desktop card is wider, not turned on its side.
42
42
  *
43
43
  * ## Two targets, and everything else is "next"
44
44
  *
@@ -99,10 +99,10 @@ export type KitchenDossierProps = {
99
99
  };
100
100
 
101
101
  /**
102
- * How far above the caption the glass begins — the ramp from clear photograph
103
- * to frost, ending exactly where the identity starts. Long, so the edge is a
104
- * gradient and not a line. The glass wrapper's `-top-32` is this same 128px;
105
- * the two must agree, or the identity sits on half-frost.
102
+ * How far above the caption the ink begins — the ramp from clear photograph
103
+ * to ink, ending exactly where the identity starts. Long, so the edge is a
104
+ * gradient and not a line. The ink wrapper's `-top-32` is this same 128px;
105
+ * the two must agree, or the identity sits on half-ink.
106
106
  */
107
107
  const GLASS_LEAD = "128px";
108
108
 
@@ -149,7 +149,7 @@ export function KitchenDossier({
149
149
  <span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
150
150
  )}
151
151
  {signature.why && (
152
- <p className="mt-1 line-clamp-2 font-serif text-[13px] italic leading-snug text-white/75">
152
+ <p className="mt-1 line-clamp-2 font-serif text-[13px] leading-snug text-white/75">
153
153
  {signature.why}
154
154
  </p>
155
155
  )}
@@ -253,7 +253,7 @@ export function KitchenDossier({
253
253
  {/* The clear photograph, with the one fact that belongs on it. */}
254
254
  <div className="relative min-h-[184px] flex-1">
255
255
  {labels.openUntil && (
256
- <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-3 text-[11.5px] font-bold text-white backdrop-blur-[2px]">
256
+ <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-3 text-[11.5px] font-bold text-white">
257
257
  <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
258
258
  {labels.openUntil}
259
259
  </span>
@@ -271,8 +271,7 @@ export function KitchenDossier({
271
271
  >
272
272
  <GlassGround
273
273
  stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
274
- peak={0.4}
275
- blur="sm"
274
+ peak={0.6}
276
275
  />
277
276
  </div>
278
277
  <div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
@@ -299,7 +298,7 @@ export function KitchenDossier({
299
298
  {parts.length > 0 && (
300
299
  <StoryPanel
301
300
  parts={parts}
302
- className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.35)] p-4 backdrop-blur-md"
301
+ className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.62)] p-4"
303
302
  />
304
303
  )}
305
304
 
@@ -36,7 +36,7 @@ const meta: Meta<typeof MenuFamilyBlock> = {
36
36
  docs: {
37
37
  description: {
38
38
  component:
39
- "The cuisines index's family card. **The mosaic is the ground**: the grid of venue photographs is the background for the entire card, and everything floats on it through three layers — the mosaic wearing the family's hue, a masked `backdrop-filter` that is absent where the photography is the subject and full where the rows begin, and an ink gradient that buys the contrast frost alone cannot. Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows frosted on the right, mosaic 3×2 rather than 2×2. The content region is `.dark`-scoped, which is how `MenuKitchenList` and `HueChip` render on the ink without a fork. Two costs accepted: a masked backdrop-filter repeated ten times down a scroll, and thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
39
+ "The cuisines index's family card. **The mosaic is the ground**: the grid of venue photographs is the background for the entire card, and everything floats on it through two layers — the mosaic wearing the family's hue, and an ink gradient that is absent where the photography is the subject and full where the rows begin. (There was a masked `backdrop-filter` between them until 0.8.11; ten of them down a scroll cost the consumer's menu page 9 ms per frame, so the ink carries the type alone now.) Phone: top to bottom. Desktop: the same layers turned ninety degrees, photo column clear on the left, rows inked on the right, mosaic 3×2 rather than 2×2. The content region is `.dark`-scoped, which is how `MenuKitchenList` and `HueChip` render on the ink without a fork. One cost accepted: thumbnails that sit on a photograph and survive only because the ink is heavy under them.",
40
40
  },
41
41
  },
42
42
  },
@@ -45,7 +45,7 @@ const meta: Meta<typeof MenuFamilyBlock> = {
45
45
  export default meta;
46
46
  type Story = StoryObj<typeof MenuFamilyBlock>;
47
47
 
48
- /** A stand-in photograph with enough variance that the frost has something to soften. */
48
+ /** A stand-in photograph with enough variance that the ink has something to sit on. */
49
49
  function photo(hue: number, label: string) {
50
50
  return (
51
51
  "data:image/svg+xml;utf8," +
@@ -78,7 +78,7 @@ export const Bare: Story = {
78
78
  },
79
79
  };
80
80
 
81
- /** Frost and ink run top to bottom: clear photography under the name, the rows on the frosted foot. */
81
+ /** The ink runs top to bottom: clear photography under the name, the rows on the inked foot. */
82
82
  export const Mobile: Story = {
83
83
  args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
84
84
  decorators: [
@@ -21,17 +21,16 @@ import type {
21
21
  *
22
22
  * 1. **The mosaic**, full-bleed, wearing the family's hue through a
23
23
  * `mix-blend-color` layer.
24
- * 2. **A masked backdrop-filter** — absent where the photography is the
25
- * subject, full where the rows begin so the pictures stay sharp where
26
- * they are looked at and go soft exactly where they are read over.
27
- * 3. **An ink gradient** over that. This is what actually buys the contrast:
28
- * frost alone blurs without darkening, and blurred mid-tones will not carry
29
- * 16px type.
24
+ * 2. **An ink gradient** — absent where the photography is the subject, full
25
+ * where the rows begin. This is what buys the contrast. There used to be a
26
+ * masked backdrop-filter under it; it was removed for the measurement
27
+ * recorded in `GlassGround` (73 blur layers cost `/menu` 9 ms per scrolled
28
+ * frame on a phone).
30
29
  *
31
- * On the phone the frost and ink run top to bottom: the name and the action
32
- * sit on clear photography, the chips and the rows on the frosted foot. On
33
- * desktop the same three layers turn ninety degrees — the photo column on the
34
- * left stays clear, the row column on the right is the frosted one. The mosaic
30
+ * On the phone the ink runs top to bottom: the name and the action sit on
31
+ * 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
35
34
  * runs 3×2 there rather than 2×2, because at 1110px a four-tile grid makes each
36
35
  * photograph too big to read as texture.
37
36
  *
@@ -105,15 +104,8 @@ const PHONE_TILES = 4;
105
104
  const GROUND = PHOTO_GROUND;
106
105
 
107
106
  /**
108
- * The frost: absent at the top, full at the foot on the phone; absent on the
109
- * left, full on the right from `lg`. The mask is what makes it a gradient of
110
- * softness rather than a frosted pane — the subject stays sharp.
111
- */
112
- const FROST =
113
- "backdrop-blur-xl [mask-image:linear-gradient(to_bottom,transparent_0%,black_42%)] lg:[mask-image:linear-gradient(to_right,transparent_36%,black_54%)]";
114
-
115
- /**
116
- * The ink. Same direction as the frost, heavier at the foot, and it is the
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
117
109
  * layer the contrast rests on. Literal rather than `var(--scrim)`: that token
118
110
  * lives in the app's stylesheet, not this package's, so a DS component reading
119
111
  * it renders nothing in Storybook and the other three consumers.
@@ -177,10 +169,7 @@ export function MenuFamilyBlock({
177
169
  gradient={gradient}
178
170
  />
179
171
 
180
- {/* ── Layer 2: the frost ──────────────────────────────────────────── */}
181
- <span aria-hidden className={cn("absolute inset-0", FROST)} />
182
-
183
- {/* ── Layer 3: the ink ────────────────────────────────────────────── */}
172
+ {/* ── Layer 2: the ink ────────────────────────────────────────────── */}
184
173
  <span aria-hidden className={cn("absolute inset-0", INK)} />
185
174
 
186
175
  {/* ── The content, floating ───────────────────────────────────────── */}
@@ -189,7 +178,7 @@ export function MenuFamilyBlock({
189
178
  the left third, with its stack anchored to the foot. */}
190
179
  <div className={cn("relative flex flex-col p-3.5 lg:min-h-[320px] lg:justify-end lg:p-5", TEXT_SCRIM)}>
191
180
  {overflow > 0 && (
192
- <span className="mb-16 self-end rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur-[2px] lg:mb-3 lg:self-start">
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">
193
182
  {labels.more(overflow)}
194
183
  </span>
195
184
  )}
@@ -201,7 +190,7 @@ export function MenuFamilyBlock({
201
190
  {labels.venueUnit(family.venueCount)}
202
191
  </span>
203
192
 
204
- {/* The action, as a frosted pill on the photography. `bg-white/12`
193
+ {/* The action, as a translucent pill on the photography. `bg-white/16`
205
194
  on the ink rather than `bg-accent`: ten filled accent buttons on
206
195
  one page is ten primary actions, and the pill reads as part of the
207
196
  picture rather than as a control laid on top of it.
@@ -212,13 +201,13 @@ export function MenuFamilyBlock({
212
201
  chevron. The shape and the label are the whole affordance. */}
213
202
  <LinkComponent
214
203
  href={family.href}
215
- className="mt-3.5 inline-flex h-10 w-max 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 hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white motion-reduce:transition-none"
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"
216
205
  >
217
206
  {labels.browse}
218
207
  </LinkComponent>
219
208
  </div>
220
209
 
221
- {/* The frosted column: chips, then the rows. */}
210
+ {/* The inked column: chips, then the rows. */}
222
211
  <div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
223
212
  <ul className="flex flex-wrap items-center gap-1.5">
224
213
  {family.priceBandLabel && (
@@ -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,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
  />
@@ -173,7 +185,7 @@ export function MenuHubHero({
173
185
  {(proof || action) && (
174
186
  <div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
175
187
  {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]">
188
+ <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
189
  {proofIcon}
178
190
  {proof}
179
191
  </span>
@@ -14,7 +14,7 @@ import { cn } from "../lib/utils";
14
14
  *
15
15
  * It owns the photographs and their colour: the ground, the tiles, the fallback
16
16
  * initial, the family's hue and an optional uniform dark overlay. It does NOT
17
- * own the glass — the scrims and frosted bands that carry a card's type run in
17
+ * own the ink — the scrims and bands that carry a card's type run in
18
18
  * a different direction on every card (down the family block's phone, across
19
19
  * its desktop, in from both ends of the concept card) and are the card's own.
20
20
  *
@@ -19,8 +19,8 @@ import { cn } from "../lib/utils";
19
19
  *
20
20
  * `surface="image"` is for card covers, where the chip sits over the photo
21
21
  * rather than in the caption block. The accent-on-canvas palette has no
22
- * contrast guarantee over an arbitrary photo, so it swaps to the same frosted
23
- * fill the place badge uses there and keeps the dashed edge — which is what
22
+ * contrast guarantee over an arbitrary photo, so it swaps to the same solid
23
+ * ink fill the place badge uses there and keeps the dashed edge — which is what
24
24
  * says "there is evidence behind this" — in the inverted colour.
25
25
  *
26
26
  * The label is passed in: the DS owns no translation keys.
@@ -64,7 +64,7 @@ export function SignatureChip({
64
64
  shared,
65
65
  "inline-flex items-center rounded-full border border-dashed",
66
66
  onImage
67
- ? "border-background/70 bg-foreground/70 text-background backdrop-blur-sm hover:bg-foreground/85"
67
+ ? "border-background/70 bg-foreground/80 text-background hover:bg-foreground/90"
68
68
  : "border-accent/40 text-accent hover:bg-accent/10",
69
69
  "focus-visible:outline-none focus-visible:ring-2",
70
70
  onImage ? "focus-visible:ring-background/70" : "focus-visible:ring-accent/40",
@@ -19,7 +19,7 @@ const meta: Meta<typeof VenueQuotes> = {
19
19
  docs: {
20
20
  description: {
21
21
  component:
22
- "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif italic against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
22
+ "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in quoted serif against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
23
23
  },
24
24
  },
25
25
  },
@@ -6,7 +6,7 @@ import type { CitedText } from "./place-types";
6
6
  *
7
7
  * These are `reviews_digest.highlights`: one to three short positives already
8
8
  * resolved into the request locale. They are the only place on a hub where a
9
- * reviewer speaks in their own register, so they are set in serif italic
9
+ * reviewer speaks in their own register, so they are set in quoted serif
10
10
  * against the sans everything else uses.
11
11
  *
12
12
  * `onCite` turns a quote into a button that opens the reviews behind it —
@@ -39,8 +39,10 @@ export function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: Ven
39
39
  const citations = quote.review_ids?.length ?? 0;
40
40
  const canCite = !!onCite && citations > 0;
41
41
 
42
+ // Upright, quoted: the italic face was the only reason the consumer's
43
+ // menu page loaded 281 KB of Fraunces italic at navigation time.
42
44
  const text = (
43
- <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] italic leading-[1.45] text-muted-foreground">
45
+ <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] leading-[1.45] text-muted-foreground">
44
46
  {`“${quote.text}”`}
45
47
  </blockquote>
46
48
  );