@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.
- package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
- package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
- package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
- package/dist/{chunk-UDN7UHJW.js → chunk-H3ZZDX5L.js} +2 -1
- package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +3 -3
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +4 -4
- package/dist/components/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +8 -8
- package/dist/components/concept-compare-rail.js +1 -1
- package/dist/components/concept-compare.js +4 -4
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/dialog.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/glass-ground.d.ts +26 -31
- package/dist/components/glass-ground.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/input-group.d.ts +2 -2
- package/dist/components/input.d.ts +1 -1
- package/dist/components/item.d.ts +4 -4
- package/dist/components/kbd.d.ts +2 -2
- package/dist/components/kitchen-dossier.d.ts +7 -7
- package/dist/components/kitchen-dossier.js +6 -7
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +9 -10
- package/dist/components/menu-family-block.js +2 -4
- package/dist/components/menu-family-card.js +1 -3
- package/dist/components/menu-hub-hero.js +10 -5
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/native-select.d.ts +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/select.d.ts +1 -1
- package/dist/components/signature-chip.d.ts +2 -2
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/spinner.d.ts +4 -4
- package/dist/components/tag.d.ts +4 -4
- package/dist/components/text.d.ts +3 -3
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.js +5 -3
- package/dist/components/weekday-theme-banner.js +1 -1
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +2 -2
- package/src/components/concept-compare-rail.tsx +2 -1
- package/src/components/concept-compare.stories.tsx +3 -3
- package/src/components/concept-compare.tsx +10 -5
- package/src/components/glass-ground.tsx +31 -56
- package/src/components/kitchen-dossier.stories.tsx +1 -1
- package/src/components/kitchen-dossier.tsx +15 -16
- package/src/components/menu-family-block.stories.tsx +3 -3
- package/src/components/menu-family-block.tsx +16 -27
- package/src/components/menu-family-card.stories.tsx +2 -2
- package/src/components/menu-family-card.tsx +6 -13
- package/src/components/menu-hub-hero.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +17 -5
- package/src/components/photo-ground.tsx +1 -1
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/venue-quotes.stories.tsx +1 -1
- package/src/components/venue-quotes.tsx +4 -2
- package/src/components/venue-wall.tsx +9 -4
- 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
|
|
139
|
-
*
|
|
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:
|
|
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/
|
|
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]
|
|
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 —
|
|
6
|
-
* photograph.
|
|
5
|
+
* GlassGround — an inked caption rising out of one edge of a photograph.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
13
|
+
* ## Why there is no blur any more
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
27
|
-
* cannot be assembled from props into a class. The
|
|
28
|
-
*
|
|
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
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
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
|
|
43
|
+
/** Where the ink begins, e.g. `"0px"` or `"24%"`. */
|
|
44
44
|
clear: string;
|
|
45
|
-
/** Where
|
|
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.
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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;
|
|
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,
|
|
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;
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* as a panel and not a hole. The same layout
|
|
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
|
|
103
|
-
* to
|
|
104
|
-
* gradient and not a line. The
|
|
105
|
-
* the two must agree, or the identity sits on half-
|
|
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]
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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. **
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
109
|
-
* left, full on the right from `lg`.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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.
|
|
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-
|
|
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=
|
|
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.
|
|
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/
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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]
|
|
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
|
);
|