@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.
- 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-7A42J73Q.js} +3 -2
- package/dist/{chunk-5RWTOT5Y.js → chunk-CNJQUHCU.js} +11 -1
- package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
- package/dist/{chunk-H3BV4VAG.js → chunk-VAXOQS7W.js} +14 -2
- package/dist/{chunk-SY6RV476.js → chunk-WCTHZKU7.js} +10 -21
- package/dist/{chunk-AKQ57NXY.js → chunk-ZQ53AGML.js} +2 -2
- package/dist/components/alert-dialog.js +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/carousel.js +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/concept-compare.js +31 -10
- package/dist/components/concept-convergence.js +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 +15 -5
- package/dist/components/item.js +15 -10
- package/dist/components/kitchen-dossier.d.ts +9 -7
- package/dist/components/kitchen-dossier.js +31 -25
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +22 -17
- package/dist/components/menu-family-block.js +62 -61
- package/dist/components/menu-family-card.js +2 -4
- package/dist/components/menu-family-tiles.d.ts +19 -0
- package/dist/components/menu-family-tiles.js +57 -0
- package/dist/components/menu-hub-hero.js +18 -11
- package/dist/components/menu-index-list.d.ts +31 -12
- package/dist/components/menu-index-list.js +68 -31
- package/dist/components/menu-intent-hero.d.ts +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-mocks.d.ts +10 -3
- package/dist/components/menu-mocks.js +21 -9
- package/dist/components/menu-types.d.ts +33 -10
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +8 -2
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/searchable-select.js +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/top-ranking-entry.js +3 -3
- package/dist/components/venue-grade-dimensions.js +1 -1
- 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.d.ts +24 -3
- package/dist/components/weekday-theme-banner.js +63 -15
- package/package.json +1 -1
- package/src/components/badge.tsx +1 -1
- package/src/components/button.stories.tsx +25 -2
- package/src/components/button.tsx +14 -2
- package/src/components/colour-dictionary.stories.tsx +1 -1
- package/src/components/concept-compare-rail.tsx +2 -1
- package/src/components/concept-compare.stories.tsx +3 -3
- package/src/components/concept-compare.tsx +50 -14
- package/src/components/glass-ground.tsx +31 -56
- package/src/components/item.tsx +69 -45
- package/src/components/kitchen-dossier.stories.tsx +1 -1
- package/src/components/kitchen-dossier.tsx +60 -40
- package/src/components/menu-family-block.stories.tsx +3 -3
- package/src/components/menu-family-block.tsx +94 -98
- package/src/components/menu-family-card.stories.tsx +2 -2
- package/src/components/menu-family-card.tsx +6 -13
- package/src/components/menu-family-tiles.stories.tsx +54 -0
- package/src/components/menu-family-tiles.test.tsx +30 -0
- package/src/components/menu-family-tiles.tsx +132 -0
- package/src/components/menu-hub-hero.test.tsx +39 -0
- package/src/components/menu-hub-hero.tsx +30 -12
- package/src/components/menu-index-list.stories.tsx +6 -1
- package/src/components/menu-index-list.tsx +101 -54
- package/src/components/menu-intent-hero.tsx +1 -1
- package/src/components/menu-mocks.ts +32 -9
- package/src/components/menu-types.ts +32 -9
- package/src/components/photo-ground.tsx +14 -2
- package/src/components/signature-chip.tsx +3 -3
- package/src/components/venue-grade-dimensions.tsx +9 -2
- 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.stories.tsx +43 -4
- package/src/components/weekday-theme-banner.tsx +95 -18
- package/dist/components/menu-intent-tiles.d.ts +0 -31
- package/dist/components/menu-intent-tiles.js +0 -50
- package/src/components/menu-intent-tiles.stories.tsx +0 -54
- package/src/components/menu-intent-tiles.tsx +0 -76
package/src/components/item.tsx
CHANGED
|
@@ -52,63 +52,87 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
|
|
|
52
52
|
);
|
|
53
53
|
Item.displayName = "Item";
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The parts take `asChild` too. An `Item asChild` around a `<button>` makes
|
|
57
|
+
* the parts the button's children, and a button may hold only phrasing
|
|
58
|
+
* content — so the parts have to be able to render as `<span>`s there.
|
|
59
|
+
*/
|
|
60
|
+
type ItemPartProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
61
|
+
asChild?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ItemMedia = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
65
|
+
({ className, asChild, ...props }, ref) => {
|
|
66
|
+
const Comp = asChild ? Slot : "div";
|
|
67
|
+
return (
|
|
68
|
+
<Comp
|
|
69
|
+
ref={ref}
|
|
70
|
+
data-slot="item-media"
|
|
71
|
+
className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
64
76
|
);
|
|
65
77
|
ItemMedia.displayName = "ItemMedia";
|
|
66
78
|
|
|
67
|
-
const ItemContent = React.forwardRef<HTMLDivElement,
|
|
68
|
-
({ className, ...props }, ref) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
const ItemContent = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
80
|
+
({ className, asChild, ...props }, ref) => {
|
|
81
|
+
const Comp = asChild ? Slot : "div";
|
|
82
|
+
return (
|
|
83
|
+
<Comp
|
|
84
|
+
ref={ref}
|
|
85
|
+
data-slot="item-content"
|
|
86
|
+
className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
76
91
|
);
|
|
77
92
|
ItemContent.displayName = "ItemContent";
|
|
78
93
|
|
|
79
|
-
const ItemTitle = React.forwardRef<HTMLDivElement,
|
|
80
|
-
({ className, ...props }, ref) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const ItemTitle = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
95
|
+
({ className, asChild, ...props }, ref) => {
|
|
96
|
+
const Comp = asChild ? Slot : "div";
|
|
97
|
+
return (
|
|
98
|
+
<Comp
|
|
99
|
+
ref={ref}
|
|
100
|
+
data-slot="item-title"
|
|
101
|
+
className={cn("truncate text-sm font-semibold text-foreground", className)}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
88
106
|
);
|
|
89
107
|
ItemTitle.displayName = "ItemTitle";
|
|
90
108
|
|
|
91
|
-
const ItemDescription = React.forwardRef<HTMLDivElement,
|
|
92
|
-
({ className, ...props }, ref) =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
const ItemDescription = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
110
|
+
({ className, asChild, ...props }, ref) => {
|
|
111
|
+
const Comp = asChild ? Slot : "div";
|
|
112
|
+
return (
|
|
113
|
+
<Comp
|
|
114
|
+
ref={ref}
|
|
115
|
+
data-slot="item-description"
|
|
116
|
+
className={cn("truncate text-xs text-muted-foreground", className)}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
100
121
|
);
|
|
101
122
|
ItemDescription.displayName = "ItemDescription";
|
|
102
123
|
|
|
103
|
-
const ItemActions = React.forwardRef<HTMLDivElement,
|
|
104
|
-
({ className, ...props }, ref) =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
const ItemActions = React.forwardRef<HTMLDivElement, ItemPartProps>(
|
|
125
|
+
({ className, asChild, ...props }, ref) => {
|
|
126
|
+
const Comp = asChild ? Slot : "div";
|
|
127
|
+
return (
|
|
128
|
+
<Comp
|
|
129
|
+
ref={ref}
|
|
130
|
+
data-slot="item-actions"
|
|
131
|
+
className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
|
|
132
|
+
{...props}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
112
136
|
);
|
|
113
137
|
ItemActions.displayName = "ItemActions";
|
|
114
138
|
|
|
@@ -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
|
*
|
|
@@ -72,6 +72,8 @@ export type KitchenDossierLabels = {
|
|
|
72
72
|
next: string;
|
|
73
73
|
/** Dimension key → translated label. */
|
|
74
74
|
dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
75
|
+
/** Beside the grade in the title row, e.g. "/10". Omit for the bare numeral. */
|
|
76
|
+
gradeUnit?: string;
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
export type KitchenDossierProps = {
|
|
@@ -99,10 +101,10 @@ export type KitchenDossierProps = {
|
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
/**
|
|
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-
|
|
104
|
+
* How far above the caption the ink begins — the ramp from clear photograph
|
|
105
|
+
* to ink, ending exactly where the identity starts. Long, so the edge is a
|
|
106
|
+
* gradient and not a line. The ink wrapper's `-top-32` is this same 128px;
|
|
107
|
+
* the two must agree, or the identity sits on half-ink.
|
|
106
108
|
*/
|
|
107
109
|
const GLASS_LEAD = "128px";
|
|
108
110
|
|
|
@@ -149,7 +151,7 @@ export function KitchenDossier({
|
|
|
149
151
|
<span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
|
|
150
152
|
)}
|
|
151
153
|
{signature.why && (
|
|
152
|
-
<p className="mt-1 line-clamp-2 font-serif text-[13px]
|
|
154
|
+
<p className="mt-1 line-clamp-2 font-serif text-[13px] leading-snug text-white/75">
|
|
153
155
|
{signature.why}
|
|
154
156
|
</p>
|
|
155
157
|
)}
|
|
@@ -202,25 +204,18 @@ export function KitchenDossier({
|
|
|
202
204
|
.sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
|
|
203
205
|
.slice(0, DIMENSION_LIMIT)
|
|
204
206
|
: [];
|
|
205
|
-
|
|
207
|
+
// The overall grade leads the title row now, so the part shows what the
|
|
208
|
+
// title cannot: the three strongest dimensions. No numeral repeated.
|
|
209
|
+
if (dimensions && strongest.length > 0) {
|
|
206
210
|
parts.push(
|
|
207
211
|
<Part key="grade" kicker={labels.theGrade}>
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
<VenueGradeDimensions
|
|
216
|
-
className="min-w-0 flex-1"
|
|
217
|
-
dimensions={dimensions}
|
|
218
|
-
labels={labels.dimensions}
|
|
219
|
-
order={strongest}
|
|
220
|
-
formatScore={formatScore}
|
|
221
|
-
/>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
212
|
+
<VenueGradeDimensions
|
|
213
|
+
className="min-w-0"
|
|
214
|
+
dimensions={dimensions}
|
|
215
|
+
labels={labels.dimensions}
|
|
216
|
+
order={strongest}
|
|
217
|
+
formatScore={formatScore}
|
|
218
|
+
/>
|
|
224
219
|
</Part>
|
|
225
220
|
);
|
|
226
221
|
}
|
|
@@ -253,7 +248,7 @@ export function KitchenDossier({
|
|
|
253
248
|
{/* The clear photograph, with the one fact that belongs on it. */}
|
|
254
249
|
<div className="relative min-h-[184px] flex-1">
|
|
255
250
|
{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.
|
|
251
|
+
<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
252
|
<i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
|
|
258
253
|
{labels.openUntil}
|
|
259
254
|
</span>
|
|
@@ -271,8 +266,7 @@ export function KitchenDossier({
|
|
|
271
266
|
>
|
|
272
267
|
<GlassGround
|
|
273
268
|
stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
|
|
274
|
-
peak={0.
|
|
275
|
-
blur="sm"
|
|
269
|
+
peak={0.6}
|
|
276
270
|
/>
|
|
277
271
|
</div>
|
|
278
272
|
<div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
|
|
@@ -282,16 +276,42 @@ export function KitchenDossier({
|
|
|
282
276
|
{place.name}
|
|
283
277
|
</LinkComponent>
|
|
284
278
|
</h3>
|
|
285
|
-
{
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
279
|
+
{/* The GRADE leads — the number the rail is named for, in the
|
|
280
|
+
grade's amber. Google's star used to sit here while the
|
|
281
|
+
grade waited in story part three; a card rated by BearMenu
|
|
282
|
+
cannot lead with a number BearMenu did not compute. The
|
|
283
|
+
star drops to the meta line, and the title row only falls
|
|
284
|
+
back to it for a kitchen with no grade. */}
|
|
285
|
+
{grade?.overall ? (
|
|
286
|
+
<span className="inline-flex shrink-0 items-baseline gap-1 text-[22px] font-extrabold leading-none tabular-nums text-[oklch(0.82_0.15_85)] lg:text-[26px]">
|
|
287
|
+
{grade.overall}
|
|
288
|
+
{labels.gradeUnit && (
|
|
289
|
+
<span className="text-[11px] font-bold text-white/70">{labels.gradeUnit}</span>
|
|
290
|
+
)}
|
|
289
291
|
</span>
|
|
292
|
+
) : (
|
|
293
|
+
rating !== undefined && (
|
|
294
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
|
|
295
|
+
{rating.toFixed(1)}
|
|
296
|
+
<Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
|
|
297
|
+
</span>
|
|
298
|
+
)
|
|
290
299
|
)}
|
|
291
300
|
</div>
|
|
292
|
-
{(meta || labels.reviewCount) && (
|
|
293
|
-
<p className="mt-1
|
|
294
|
-
{
|
|
301
|
+
{(meta || labels.reviewCount || (grade?.overall && rating !== undefined)) && (
|
|
302
|
+
<p className="mt-1 flex min-w-0 items-center gap-1 text-[12.5px] text-white/70">
|
|
303
|
+
{(meta || labels.reviewCount) && (
|
|
304
|
+
<span className="truncate">
|
|
305
|
+
{[meta, labels.reviewCount].filter(Boolean).join(" · ")}
|
|
306
|
+
</span>
|
|
307
|
+
)}
|
|
308
|
+
{grade?.overall && rating !== undefined && (
|
|
309
|
+
<span className="inline-flex shrink-0 items-center gap-1 tabular-nums">
|
|
310
|
+
{(meta || labels.reviewCount) && <span aria-hidden>·</span>}
|
|
311
|
+
{rating.toFixed(1)}
|
|
312
|
+
<Star className="h-3 w-3" aria-hidden />
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
295
315
|
</p>
|
|
296
316
|
)}
|
|
297
317
|
</div>
|
|
@@ -299,7 +319,7 @@ export function KitchenDossier({
|
|
|
299
319
|
{parts.length > 0 && (
|
|
300
320
|
<StoryPanel
|
|
301
321
|
parts={parts}
|
|
302
|
-
className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.
|
|
322
|
+
className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.62)] p-4"
|
|
303
323
|
/>
|
|
304
324
|
)}
|
|
305
325
|
|
|
@@ -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: [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Wallet } from "lucide-react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
|
+
import { buttonVariants } from "./button";
|
|
3
4
|
import { HueChip } from "./hue-chip";
|
|
4
5
|
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
5
6
|
import { Separator } from "./separator";
|
|
@@ -12,28 +13,27 @@ import type {
|
|
|
12
13
|
} from "./menu-types";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
* MenuFamilyBlock — the cuisines index's family card.
|
|
16
|
+
* MenuFamilyBlock — the cuisines index's family card. One photograph is the ground.
|
|
16
17
|
*
|
|
17
18
|
* ## Three layers, and everything floats on them
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* One venue photograph is the background for the ENTIRE card, edge to edge,
|
|
21
|
+
* and the facts sit on it. Three layers do the work:
|
|
21
22
|
*
|
|
22
|
-
* 1. **The
|
|
23
|
-
* `mix-blend-color` layer.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* 1. **The photograph**, full-bleed, wearing the family's hue through a
|
|
24
|
+
* `mix-blend-color` layer. It was a mosaic of six; the design draws one
|
|
25
|
+
* plate, and six tiles under the ink read as texture rather than a room.
|
|
26
|
+
* 2. **An ink gradient** — absent where the photography is the subject, full
|
|
27
|
+
* where the rows begin. This is what buys the contrast. There used to be a
|
|
28
|
+
* masked backdrop-filter under it; it was removed for the measurement
|
|
29
|
+
* recorded in `GlassGround` (73 blur layers cost `/menu` 9 ms per scrolled
|
|
30
|
+
* frame on a phone).
|
|
30
31
|
*
|
|
31
|
-
* On the phone the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* photograph too big to read as texture.
|
|
32
|
+
* On the phone the ink runs top to bottom: the name and the action sit on
|
|
33
|
+
* clear photography, the chips and the rows on the inked foot. On desktop the
|
|
34
|
+
* photo column on the left (a third) stays clear, and the row column on the
|
|
35
|
+
* right is opaque paper in the card's own dark — three venue rows never sit
|
|
36
|
+
* on a photograph.
|
|
37
37
|
*
|
|
38
38
|
* ## The dark scope
|
|
39
39
|
*
|
|
@@ -81,6 +81,12 @@ export type MenuFamilyBlockLabels = {
|
|
|
81
81
|
priceBand: string;
|
|
82
82
|
/** Main action, e.g. "Browse Italian". Sits on the photography — keep it short. */
|
|
83
83
|
browse: string;
|
|
84
|
+
/**
|
|
85
|
+
* The action from `lg`, where the photo column is a third of the card and
|
|
86
|
+
* "Browse Bakeries & patisseries" does not fit — e.g. "Browse". The full
|
|
87
|
+
* `browse` stays the accessible name.
|
|
88
|
+
*/
|
|
89
|
+
browseShort?: string;
|
|
84
90
|
/** Passed through to the venue rows. */
|
|
85
91
|
kitchens: MenuKitchenListLabels;
|
|
86
92
|
};
|
|
@@ -97,29 +103,22 @@ export type MenuFamilyBlockProps = {
|
|
|
97
103
|
className?: string;
|
|
98
104
|
};
|
|
99
105
|
|
|
100
|
-
/** 2×2 on the phone, 3×2 from `lg`. Tiles past the fourth are desktop-only. */
|
|
101
|
-
const MOSAIC_TILES = 6;
|
|
102
|
-
const PHONE_TILES = 4;
|
|
103
|
-
|
|
104
106
|
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
105
107
|
const GROUND = PHOTO_GROUND;
|
|
106
108
|
|
|
107
109
|
/**
|
|
108
|
-
* The
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
110
|
+
* The ink. Nearly absent at the top, heavier at the foot on the phone — the
|
|
111
|
+
* top 40% is the photograph, the rows sit on 0.92+; a to-right ramp from
|
|
112
|
+
* `lg`, where the row column brings its own opaque paper anyway. It is the
|
|
117
113
|
* layer the contrast rests on. Literal rather than `var(--scrim)`: that token
|
|
118
114
|
* lives in the app's stylesheet, not this package's, so a DS component reading
|
|
119
115
|
* it renders nothing in Storybook and the other three consumers.
|
|
120
116
|
*/
|
|
121
117
|
const INK =
|
|
122
|
-
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.
|
|
118
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)] lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.38)_0%,oklch(0.16_0.012_55_/_0.6)_30%,oklch(0.16_0.012_55_/_0.92)_46%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
119
|
+
|
|
120
|
+
/** The row column's paper from `lg`: the card's dark, opaque. */
|
|
121
|
+
const PAPER = "lg:bg-[oklch(0.21_0.012_55)]";
|
|
123
122
|
|
|
124
123
|
/**
|
|
125
124
|
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
@@ -148,10 +147,12 @@ export function MenuFamilyBlock({
|
|
|
148
147
|
linkComponent: LinkComponent = DefaultLink,
|
|
149
148
|
className,
|
|
150
149
|
}: MenuFamilyBlockProps) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
const [photo] = family.photoUrls ?? [];
|
|
151
|
+
// "+N more" counts past the rows shown, which are the venues the reader can
|
|
152
|
+
// see — the photograph is one of them, not a venue on its own.
|
|
154
153
|
const venues = family.venues ?? [];
|
|
154
|
+
const overflow = Math.max(family.venueCount - venues.length, 0);
|
|
155
|
+
const initial = (family.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
155
156
|
|
|
156
157
|
return (
|
|
157
158
|
<article
|
|
@@ -163,33 +164,26 @@ export function MenuFamilyBlock({
|
|
|
163
164
|
className
|
|
164
165
|
)}
|
|
165
166
|
>
|
|
166
|
-
{/* ── Layer 1: the
|
|
167
|
-
{/*
|
|
168
|
-
|
|
169
|
-
all, one slot with the family's initial. */}
|
|
170
|
-
<PhotoGround
|
|
171
|
-
photos={photos.length > 0 ? photos : [undefined]}
|
|
172
|
-
initials={initial}
|
|
173
|
-
mosaicClassName={
|
|
174
|
-
photos.length > 0 ? "grid-cols-2 grid-rows-2 lg:grid-cols-3" : "grid-cols-1"
|
|
175
|
-
}
|
|
176
|
-
phoneTiles={PHONE_TILES}
|
|
177
|
-
gradient={gradient}
|
|
178
|
-
/>
|
|
179
|
-
|
|
180
|
-
{/* ── Layer 2: the frost ──────────────────────────────────────────── */}
|
|
181
|
-
<span aria-hidden className={cn("absolute inset-0", FROST)} />
|
|
167
|
+
{/* ── Layer 1: the photograph, wearing the family's colour ───────── */}
|
|
168
|
+
{/* One venue's photograph, or the family's initial on the ground. */}
|
|
169
|
+
<PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
|
|
182
170
|
|
|
183
|
-
{/* ── Layer
|
|
171
|
+
{/* ── Layer 2: the ink ────────────────────────────────────────────── */}
|
|
184
172
|
<span aria-hidden className={cn("absolute inset-0", INK)} />
|
|
185
173
|
|
|
186
174
|
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
187
|
-
<div className="relative grid grid-cols-1 lg:grid-cols-[
|
|
188
|
-
{/* The photo column. On the phone it is the card's head
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
<div className="relative grid grid-cols-1 lg:grid-cols-[34%_1fr]">
|
|
176
|
+
{/* The photo column. On the phone it is the card's head, with the
|
|
177
|
+
overflow pill pinned to the corner and the photograph clear above
|
|
178
|
+
the name; on desktop the left third, its stack anchored to the foot. */}
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
"relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5",
|
|
182
|
+
TEXT_SCRIM
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
191
185
|
{overflow > 0 && (
|
|
192
|
-
<span className="
|
|
186
|
+
<span className="absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white lg:static lg:mb-3 lg:self-start">
|
|
193
187
|
{labels.more(overflow)}
|
|
194
188
|
</span>
|
|
195
189
|
)}
|
|
@@ -197,55 +191,57 @@ export function MenuFamilyBlock({
|
|
|
197
191
|
<h3 className="text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[30px]">
|
|
198
192
|
{family.name}
|
|
199
193
|
</h3>
|
|
200
|
-
|
|
194
|
+
{/* The count and the band on one line: two facts about the family,
|
|
195
|
+
read together — "96 kitchens, 14–46 lei". */}
|
|
196
|
+
<span className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[13px] font-medium tabular-nums text-white/70">
|
|
201
197
|
{labels.venueUnit(family.venueCount)}
|
|
198
|
+
{family.priceBandLabel && (
|
|
199
|
+
<span
|
|
200
|
+
className="inline-flex items-center gap-1"
|
|
201
|
+
aria-label={`${labels.priceBand}: ${family.priceBandLabel}`}
|
|
202
|
+
>
|
|
203
|
+
<Wallet className="h-3.5 w-3.5 shrink-0 text-white/60" aria-hidden />
|
|
204
|
+
{family.priceBandLabel}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
202
207
|
</span>
|
|
203
208
|
|
|
204
|
-
{/* The action
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
picture rather than as a control laid on top of it.
|
|
208
|
-
|
|
209
|
-
No glyph. An arrow read as "leaves the site" beside a label that
|
|
210
|
-
already says where it goes, and a chevron would have made the pill
|
|
211
|
-
one more row: every venue row to its right ends in exactly that
|
|
212
|
-
chevron. The shape and the label are the whole affordance. */}
|
|
209
|
+
{/* The hub's one action on ink. No glyph: a chevron would have made
|
|
210
|
+
the pill one more row, and every venue row beside it ends in
|
|
211
|
+
exactly that chevron. */}
|
|
213
212
|
<LinkComponent
|
|
214
213
|
href={family.href}
|
|
215
|
-
|
|
214
|
+
aria-label={labels.browse}
|
|
215
|
+
className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
|
|
216
216
|
>
|
|
217
|
-
{labels.
|
|
217
|
+
{labels.browseShort ? (
|
|
218
|
+
<>
|
|
219
|
+
<span className="lg:hidden">{labels.browse}</span>
|
|
220
|
+
<span className="hidden lg:inline">{labels.browseShort}</span>
|
|
221
|
+
</>
|
|
222
|
+
) : (
|
|
223
|
+
labels.browse
|
|
224
|
+
)}
|
|
218
225
|
</LinkComponent>
|
|
219
226
|
</div>
|
|
220
227
|
|
|
221
|
-
{/* The
|
|
222
|
-
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
{family.priceBandLabel}
|
|
235
|
-
</HueChip>
|
|
236
|
-
)}
|
|
237
|
-
{family.chips?.map((chip) => (
|
|
238
|
-
/* Label only. The counts are VENUES beside a dish group's name,
|
|
239
|
-
which every reader takes for dishes. */
|
|
240
|
-
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
241
|
-
{chip.label}
|
|
242
|
-
</HueChip>
|
|
243
|
-
))}
|
|
244
|
-
</ul>
|
|
228
|
+
{/* The row column: chips, then the rows, on paper from `lg`. */}
|
|
229
|
+
<div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5", PAPER)}>
|
|
230
|
+
{family.chips && family.chips.length > 0 && (
|
|
231
|
+
<ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
|
|
232
|
+
{family.chips.map((chip) => (
|
|
233
|
+
/* Label only. The counts are VENUES beside a dish group's name,
|
|
234
|
+
which every reader takes for dishes. */
|
|
235
|
+
<HueChip key={chip.slug} as="li" hue={hue}>
|
|
236
|
+
{chip.label}
|
|
237
|
+
</HueChip>
|
|
238
|
+
))}
|
|
239
|
+
</ul>
|
|
240
|
+
)}
|
|
245
241
|
|
|
246
242
|
{venues.length > 0 && (
|
|
247
243
|
<>
|
|
248
|
-
<Separator className="my-3" />
|
|
244
|
+
<Separator className="my-3 via-white/15" />
|
|
249
245
|
<MenuKitchenList
|
|
250
246
|
kitchens={venues}
|
|
251
247
|
labels={labels.kitchens}
|
|
@@ -281,12 +277,12 @@ export function MenuFamilyBlockSkeleton({
|
|
|
281
277
|
className={cn("dark relative overflow-hidden rounded-[14px]", GROUND, className)}
|
|
282
278
|
aria-hidden
|
|
283
279
|
>
|
|
284
|
-
<div className="grid grid-cols-1 lg:grid-cols-[
|
|
285
|
-
<div className="flex flex-col p-3.5 lg:min-h-[320px] lg:
|
|
286
|
-
<Skeleton className="
|
|
280
|
+
<div className="grid grid-cols-1 lg:grid-cols-[34%_1fr]">
|
|
281
|
+
<div className="relative flex min-h-[220px] flex-col justify-end p-3.5 lg:min-h-[320px] lg:p-5">
|
|
282
|
+
<Skeleton className="absolute right-3.5 top-3.5 h-6 w-[72px] rounded-full lg:static lg:mb-3" />
|
|
287
283
|
<Skeleton className="h-7 w-[60%]" />
|
|
288
|
-
<Skeleton className="mt-2 h-3.5 w-[
|
|
289
|
-
<Skeleton className="mt-3.5 h-
|
|
284
|
+
<Skeleton className="mt-2 h-3.5 w-[150px]" />
|
|
285
|
+
<Skeleton className="mt-3.5 h-11 w-[160px] rounded-full" />
|
|
290
286
|
</div>
|
|
291
287
|
<div className="flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5">
|
|
292
288
|
<div className="flex gap-1.5">
|