@bearmenu/ui 0.8.8 → 0.8.10
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-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +110 -0
- package/dist/components/concept-compare.js +238 -0
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +2 -2
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +38 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +322 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +314 -0
- package/src/components/menu-family-card.stories.tsx +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Wallet } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { HueChip } from "./hue-chip";
|
|
4
|
+
import type { MenuFamilyPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
5
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MenuFamilyCard — a browsable grouping of venues: a cuisine on the dishes tab,
|
|
9
|
+
* a drink family on the drinks tab. The hub's doorway to a family.
|
|
10
|
+
*
|
|
11
|
+
* ## A doorway, not a summary
|
|
12
|
+
*
|
|
13
|
+
* The cuisines index's `MenuFamilyBlock` is the family's SUMMARY: photography,
|
|
14
|
+
* chips, a price band and three venues. This card is the same family drawn
|
|
15
|
+
* one step earlier, in a row of three on the hub, where the reader is choosing
|
|
16
|
+
* a family and not yet a venue — so it carries the same photography, the same
|
|
17
|
+
* chips and the same "Browse" action, and lists no venue. A venue on the hub
|
|
18
|
+
* card was tried and it was the first venue's signature relabelled as the
|
|
19
|
+
* family's most named, which was a lie at three-card width.
|
|
20
|
+
*
|
|
21
|
+
* ## The count does the scanning
|
|
22
|
+
*
|
|
23
|
+
* The card is one number, one name and the groups the family consists of,
|
|
24
|
+
* over a mosaic of its venues. The count is VENUES: cuisine exists on places
|
|
25
|
+
* and nowhere else — no listing endpoint will tell you how many dishes are
|
|
26
|
+
* Italian — so a card counting dishes cannot be built and this one does not
|
|
27
|
+
* try. The chips are plain labels: at three cards across, chips carrying
|
|
28
|
+
* numbers turned the panel into a table, and the numbers were kitchens, which
|
|
29
|
+
* nobody reads a chip as.
|
|
30
|
+
*
|
|
31
|
+
* ## One link
|
|
32
|
+
*
|
|
33
|
+
* The whole card is the link and the "Browse" pill is its visible affordance,
|
|
34
|
+
* not a second link — a pill inside a link is a nested anchor, and a pill that
|
|
35
|
+
* is the only link leaves the photography, the number and the name inert.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
39
|
+
export type MenuFamilyCardLinkProps = MenuLinkProps;
|
|
40
|
+
|
|
41
|
+
function DefaultLink({ href, ...props }: MenuFamilyCardLinkProps) {
|
|
42
|
+
return <a href={href} {...props} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MenuFamilyCardLabels = {
|
|
46
|
+
/** Unit under the count, e.g. "KITCHENS". */
|
|
47
|
+
venueUnit: string;
|
|
48
|
+
/**
|
|
49
|
+
* Price-band caption, e.g. "kitchen price band". Never drawn — it names the
|
|
50
|
+
* pill for a screen reader, which would otherwise be read two numbers and a
|
|
51
|
+
* currency.
|
|
52
|
+
*/
|
|
53
|
+
priceBand: string;
|
|
54
|
+
/** The action pill, e.g. "Browse Italian". Sits on the photography — keep it short. */
|
|
55
|
+
browse: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type MenuFamilyCardProps = {
|
|
59
|
+
family: MenuFamilyPreview;
|
|
60
|
+
labels: MenuFamilyCardLabels;
|
|
61
|
+
href: string;
|
|
62
|
+
/** Finished CSS colour for the chips. */
|
|
63
|
+
hue?: string;
|
|
64
|
+
/** Finished CSS gradient — the family's colour over the mosaic. */
|
|
65
|
+
gradient?: string;
|
|
66
|
+
/** Composed price band, e.g. "32–84 lei". */
|
|
67
|
+
priceBandLabel?: string;
|
|
68
|
+
linkComponent?: MenuLinkComponent;
|
|
69
|
+
className?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A 2×2, not a 2×4.
|
|
74
|
+
*
|
|
75
|
+
* Four cells at card width give each photograph enough room to read as a room
|
|
76
|
+
* rather than as a swatch, and four cells match the four venues a caller
|
|
77
|
+
* actually has, so no photo repeats. At eight cells the list wrapped and put
|
|
78
|
+
* the SAME image directly above itself in a column, which is the one repeat a
|
|
79
|
+
* mosaic cannot hide.
|
|
80
|
+
*/
|
|
81
|
+
const MOSAIC_CELLS = 4;
|
|
82
|
+
|
|
83
|
+
/** The card's ground is `PhotoGround`'s, so the two cannot differ. */
|
|
84
|
+
const GROUND = PHOTO_GROUND;
|
|
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
|
+
/**
|
|
94
|
+
* The ink. The count and the name sit at opposite ends of the card, so both
|
|
95
|
+
* ends are dark — a gradient that cleared in the middle looked better empty
|
|
96
|
+
* and made the type unreadable the moment there were real photographs behind
|
|
97
|
+
* it. Literal rather than `var(--scrim)`: that token lives in the app's
|
|
98
|
+
* stylesheet, not this package's.
|
|
99
|
+
*/
|
|
100
|
+
const INK =
|
|
101
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.78)_0%,oklch(0.16_0.012_55_/_0.5)_34%,oklch(0.16_0.012_55_/_0.86)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
102
|
+
|
|
103
|
+
export function MenuFamilyCard({
|
|
104
|
+
family,
|
|
105
|
+
labels,
|
|
106
|
+
href,
|
|
107
|
+
hue,
|
|
108
|
+
gradient,
|
|
109
|
+
priceBandLabel,
|
|
110
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
111
|
+
className,
|
|
112
|
+
}: MenuFamilyCardProps) {
|
|
113
|
+
const photos = family.venue_photo_urls ?? [];
|
|
114
|
+
/* Fewer venues than cells: the mosaic repeats rather than leaving bare
|
|
115
|
+
ground, because under the tint and the ink a repeated room is a texture
|
|
116
|
+
and an empty cell is a hole. No venues at all: one cell, the initial. */
|
|
117
|
+
const tiles = photos.length
|
|
118
|
+
? Array.from({ length: MOSAIC_CELLS }, (_, cell) => photos[cell % photos.length])
|
|
119
|
+
: [undefined];
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<LinkComponent
|
|
123
|
+
href={href}
|
|
124
|
+
className={cn(
|
|
125
|
+
"group dark relative isolate flex min-h-[280px] flex-col justify-between overflow-hidden rounded-[14px] border border-border p-3.5",
|
|
126
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white",
|
|
127
|
+
GROUND,
|
|
128
|
+
className
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{/* ── Layer 1: the mosaic ─────────────────────────────────────────── */}
|
|
132
|
+
<PhotoGround
|
|
133
|
+
className="-z-30"
|
|
134
|
+
photos={tiles}
|
|
135
|
+
initials={family.name.charAt(0)}
|
|
136
|
+
mosaicClassName={photos.length ? "grid-cols-2 grid-rows-2" : "grid-cols-1"}
|
|
137
|
+
gradient={gradient}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{/* ── Layer 2: the frost ──────────────────────────────────────────── */}
|
|
141
|
+
<span aria-hidden className={cn("absolute inset-0 -z-20", FROST)} />
|
|
142
|
+
|
|
143
|
+
{/* ── Layer 3: the ink ────────────────────────────────────────────── */}
|
|
144
|
+
<span aria-hidden className={cn("absolute inset-0 -z-10", INK)} />
|
|
145
|
+
|
|
146
|
+
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
147
|
+
<span className="block text-white">
|
|
148
|
+
<b className="block text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
|
|
149
|
+
{family.venue_count}
|
|
150
|
+
</b>
|
|
151
|
+
<span className="mt-1 block text-[9.5px] font-bold uppercase tracking-[0.12em] opacity-80">
|
|
152
|
+
{labels.venueUnit}
|
|
153
|
+
</span>
|
|
154
|
+
</span>
|
|
155
|
+
|
|
156
|
+
<span className="block">
|
|
157
|
+
<span className="block text-balance font-serif text-[24px] font-semibold leading-tight tracking-[-0.02em] text-white">
|
|
158
|
+
{family.name}
|
|
159
|
+
</span>
|
|
160
|
+
|
|
161
|
+
{(priceBandLabel || (family.chips?.length ?? 0) > 0) && (
|
|
162
|
+
<span className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
|
163
|
+
{priceBandLabel && (
|
|
164
|
+
/* No hue: a fact about money, not about the family. In the dark
|
|
165
|
+
scope `HueChip`'s `--muted` fallback is the dark pill. */
|
|
166
|
+
<HueChip
|
|
167
|
+
className="font-bold tabular-nums"
|
|
168
|
+
aria-label={`${labels.priceBand}: ${priceBandLabel}`}
|
|
169
|
+
icon={<Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />}
|
|
170
|
+
>
|
|
171
|
+
{priceBandLabel}
|
|
172
|
+
</HueChip>
|
|
173
|
+
)}
|
|
174
|
+
{family.chips?.map((chip) => (
|
|
175
|
+
<HueChip key={chip.slug} hue={hue}>
|
|
176
|
+
{chip.label}
|
|
177
|
+
</HueChip>
|
|
178
|
+
))}
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* The action, as a frosted pill on the photography — the block's
|
|
183
|
+
pill, so the two cards read as one family. A `span`, because the
|
|
184
|
+
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">
|
|
186
|
+
{labels.browse}
|
|
187
|
+
</span>
|
|
188
|
+
</span>
|
|
189
|
+
</LinkComponent>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuField } from "./menu-field";
|
|
3
|
+
import { mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuField> = {
|
|
6
|
+
title: "Menu/MenuField",
|
|
7
|
+
component: MenuField,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The photoless cover for a dish or drink: the item's own name repeating across a shallow diagonal over a gradient. Menu-item photography is AI-generated and never shown, so the type IS the cover. Rows fade symmetrically out from one strong centre line, which is what keeps them reading as texture in the colour rather than as a watermark stamped on top of it. The gradient is injected because resolving a category to a hue needs app-owned enums.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof MenuField>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
name: "Truffle Tagliatelle",
|
|
25
|
+
gradient: mockGradients.pasta,
|
|
26
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A long name repeats fewer times per line, so the texture opens up. */
|
|
31
|
+
export const LongName: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
name: "Burrata & heirloom tomato with basil oil",
|
|
34
|
+
gradient: mockGradients.greens,
|
|
35
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Short names tile densely — the falloff is what stops that reading as noise. */
|
|
40
|
+
export const ShortName: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
name: "Mici",
|
|
43
|
+
gradient: mockGradients.meat,
|
|
44
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `seedKey` fixes the horizontal stagger. Two cards for the same concept at
|
|
50
|
+
* different venues break identically; pass the item slug and they diverge.
|
|
51
|
+
*/
|
|
52
|
+
export const EveryHue: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<div className="flex flex-wrap gap-3">
|
|
55
|
+
{Object.entries(mockGradients).map(([name, gradient]) => (
|
|
56
|
+
<MenuField
|
|
57
|
+
key={name}
|
|
58
|
+
name={name}
|
|
59
|
+
gradient={gradient}
|
|
60
|
+
className="h-[120px] w-[150px] rounded-xl"
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** At tile scale the type has to survive being small. This is the real size. */
|
|
68
|
+
export const AtTileScale: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
name: "Chicken wings, 6 pc",
|
|
71
|
+
gradient: mockGradients.meat,
|
|
72
|
+
className: "aspect-[5/4] w-[180px] rounded-xl",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MenuField — the photoless cover surface for a dish or drink.
|
|
5
|
+
*
|
|
6
|
+
* Menu-item photography is AI-generated and never shown, so the item's own name
|
|
7
|
+
* repeats across a shallow diagonal over a gradient. The type IS the cover.
|
|
8
|
+
*
|
|
9
|
+
* The rows fade SYMMETRICALLY out from a single strong row at the centre. That
|
|
10
|
+
* shape is what keeps the type reading as texture in the colour rather than as
|
|
11
|
+
* a watermark stamped on top of it: the eye lands on one legible line and the
|
|
12
|
+
* rest falls away in both directions. A uniform wash — or a random per-row
|
|
13
|
+
* alpha — reads as noise at every alpha high enough to see.
|
|
14
|
+
*
|
|
15
|
+
* The alpha curve is IDENTICAL for every hue. Scaling it per hue to even out
|
|
16
|
+
* how white type reads over different grounds also moves where rows fall below
|
|
17
|
+
* visibility, so a damped hue lost rows and one card showed five bands where
|
|
18
|
+
* its neighbour showed three. Correct contrast in the `gradient` you pass, not
|
|
19
|
+
* in the curve.
|
|
20
|
+
*
|
|
21
|
+
* ## Why `gradient` is a prop
|
|
22
|
+
*
|
|
23
|
+
* Resolving a category slug to a hue needs the app's category enums, which the
|
|
24
|
+
* DS does not take. Callers pass a finished CSS gradient — fe from its own
|
|
25
|
+
* category-colour table, post-generator from whatever it is filming. The DS
|
|
26
|
+
* owns the typography and the falloff; the consumer owns the palette.
|
|
27
|
+
*
|
|
28
|
+
* Server-safe: no state, no effects. The row geometry is a 13-iteration loop
|
|
29
|
+
* computed on every render, which is cheaper than the hook that would let it be
|
|
30
|
+
* skipped.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type MenuFieldProps = {
|
|
34
|
+
/** Item name — rendered as the repeating ghost. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** CSS `background-image` value. See the note above on why this is injected. */
|
|
37
|
+
gradient: string;
|
|
38
|
+
/**
|
|
39
|
+
* Deterministic stagger seed. Pass the PARENT CONCEPT SLUG so one dish breaks
|
|
40
|
+
* its lines identically at every venue and across re-sorts. Defaults to
|
|
41
|
+
* `name`, which is stable but shared by every venue's version of a dish.
|
|
42
|
+
*/
|
|
43
|
+
seedKey?: string;
|
|
44
|
+
/**
|
|
45
|
+
* `repeat` (default) — the name as texture, thirteen fading rows. Right at
|
|
46
|
+
* swatch and thumbnail sizes, where any single line would be unreadable.
|
|
47
|
+
*
|
|
48
|
+
* `display` — the name ONCE, set large and bled off the edges. Right when the
|
|
49
|
+
* tile is the primary object on its row and the name is the thing being sold;
|
|
50
|
+
* the repeat treatment at that size reads as wallpaper rather than as a dish.
|
|
51
|
+
* Long names clip on purpose — the caption underneath carries the full text.
|
|
52
|
+
*/
|
|
53
|
+
variant?: "repeat" | "display";
|
|
54
|
+
className?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Odd, so there is a true middle row to peak on. Fixed rather than measured:
|
|
59
|
+
* the field is only ever 106–260px tall, 13 rows covers the tallest of those
|
|
60
|
+
* once the 11° rotation and the 180% oversize are accounted for, and the rest
|
|
61
|
+
* is clipped. Measuring would cost a layout effect per card on a grid that
|
|
62
|
+
* renders fifty at a time.
|
|
63
|
+
*/
|
|
64
|
+
const GHOST_ROWS = 13;
|
|
65
|
+
const GHOST_CENTRE = (GHOST_ROWS - 1) / 2;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Alpha at the centre row and at the outermost rows.
|
|
69
|
+
*
|
|
70
|
+
* The peak has to clear the point where white stops reading as white. White at
|
|
71
|
+
* a low alpha over a saturated field does not composite to a pale version of
|
|
72
|
+
* itself — it composites to a desaturated version of the FIELD, which the eye
|
|
73
|
+
* calls grey. At 0.30 the brightest rows read as grey text on colour; the type
|
|
74
|
+
* only resolves as an off-white shade of the field once the centre gets enough
|
|
75
|
+
* weight to pull lightness up and chroma down.
|
|
76
|
+
*/
|
|
77
|
+
const ALPHA_PEAK = 0.44;
|
|
78
|
+
const ALPHA_EDGE = 0;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Falloff steepness. High on purpose: only the middle band should read, with
|
|
82
|
+
* everything above and below dropping to nothing well before the card edge. At
|
|
83
|
+
* 1.6 the outer rows were still legible and the field looked tiled rather than
|
|
84
|
+
* lit; 1 would be a flat linear ramp leaving every row half-visible.
|
|
85
|
+
*/
|
|
86
|
+
const FALLOFF_EXPONENT = 3.4;
|
|
87
|
+
|
|
88
|
+
/** Target line width in characters, so short names repeat more than long ones. */
|
|
89
|
+
const GHOST_LINE_CHARS = 56;
|
|
90
|
+
|
|
91
|
+
/** djb2. Same input, same break pattern, always — and no `Math.random` to hydrate-mismatch. */
|
|
92
|
+
function hashString(input: string): number {
|
|
93
|
+
let hash = 5381;
|
|
94
|
+
for (let i = 0; i < input.length; i++) hash = (hash * 33) ^ input.charCodeAt(i);
|
|
95
|
+
return Math.abs(hash);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function MenuField({
|
|
99
|
+
name,
|
|
100
|
+
gradient,
|
|
101
|
+
seedKey,
|
|
102
|
+
variant = "repeat",
|
|
103
|
+
className,
|
|
104
|
+
}: MenuFieldProps) {
|
|
105
|
+
if (variant === "display") {
|
|
106
|
+
// Fit the name to the tile. The longest word sets the width the type may
|
|
107
|
+
// take (a `font-black` uppercase glyph advances ~0.62em on average), the
|
|
108
|
+
// word count sets the height (leading 0.9). Whichever is tighter wins,
|
|
109
|
+
// capped at the 19cqw a short name used to get and floored at 9cqw so a
|
|
110
|
+
// very long name becomes small type, not clipped type. A little bleed past
|
|
111
|
+
// the padding is fine; three lines of letters taller than the tile is not.
|
|
112
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
113
|
+
const longestWord = Math.max(1, ...words.map((word) => word.length));
|
|
114
|
+
const lineCount = Math.max(1, words.length);
|
|
115
|
+
const byWidth = (96 / (longestWord * 0.62)).toFixed(1);
|
|
116
|
+
const byHeight = (90 / (lineCount * 0.9)).toFixed(1);
|
|
117
|
+
const fontSize = `clamp(9cqw, min(19cqw, ${byWidth}cqw, ${byHeight}cqh), 19cqw)`;
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className={cn("relative isolate overflow-hidden", className)}
|
|
121
|
+
// `size` containment (not the wrapper's inline-size) so `cqh` resolves
|
|
122
|
+
// against THIS tile's height. Safe: the field is sized by its parent's
|
|
123
|
+
// aspect box, never by its own content.
|
|
124
|
+
style={{ backgroundImage: gradient, containerType: "size" }}
|
|
125
|
+
>
|
|
126
|
+
{/*
|
|
127
|
+
The name ONCE, in translucent white, sized to FIT the tile — see the
|
|
128
|
+
arithmetic above. `cqw`/`cqh` units so the type scales with the card
|
|
129
|
+
instead of being a fixed pixel size that looks right in a six-across
|
|
130
|
+
grid and absurd anywhere else. The caption below carries the full
|
|
131
|
+
text, which is also why this is `aria-hidden`.
|
|
132
|
+
|
|
133
|
+
Centred on both axes so a short name grows symmetrically and a long
|
|
134
|
+
one stacks its words in the middle. NO `break-words`: it split names
|
|
135
|
+
mid-glyph ("PIZZA NEW CROC / O"), which reads as a rendering fault.
|
|
136
|
+
Words wrap whole.
|
|
137
|
+
*/}
|
|
138
|
+
<span
|
|
139
|
+
aria-hidden
|
|
140
|
+
className="pointer-events-none absolute inset-0 flex select-none items-center justify-center p-[4cqw] text-center font-black uppercase leading-[0.9] tracking-[-0.03em] text-white/[0.26]"
|
|
141
|
+
style={{ fontSize }}
|
|
142
|
+
>
|
|
143
|
+
{name}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const unit = `${name} · `;
|
|
150
|
+
const repeats = Math.max(2, Math.ceil(GHOST_LINE_CHARS / Math.max(unit.length, 1)));
|
|
151
|
+
const ghostLine = unit.repeat(repeats);
|
|
152
|
+
const seed = hashString(seedKey ?? name);
|
|
153
|
+
|
|
154
|
+
const rows = Array.from({ length: GHOST_ROWS }, (_, row) => {
|
|
155
|
+
const distance = Math.abs(row - GHOST_CENTRE) / GHOST_CENTRE;
|
|
156
|
+
const alpha = ALPHA_EDGE + (ALPHA_PEAK - ALPHA_EDGE) * Math.pow(1 - distance, FALLOFF_EXPONENT);
|
|
157
|
+
return { row, alpha, offset: ((seed >> (row % 12)) % 90) - 45 };
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div
|
|
162
|
+
className={cn("relative isolate overflow-hidden", className)}
|
|
163
|
+
style={{ backgroundImage: gradient }}
|
|
164
|
+
>
|
|
165
|
+
{/*
|
|
166
|
+
`aria-hidden`: the ghost duplicates the caption beside it, and a screen
|
|
167
|
+
reader announcing "Arancini" thirteen times is noise. `select-none` so
|
|
168
|
+
dragging across a grid does not lift a wall of repeated names into the
|
|
169
|
+
clipboard.
|
|
170
|
+
*/}
|
|
171
|
+
<div
|
|
172
|
+
aria-hidden
|
|
173
|
+
className="pointer-events-none absolute left-1/2 top-1/2 flex h-[180%] w-[180%] -translate-x-1/2 -translate-y-1/2 -rotate-[11deg] select-none flex-col justify-center"
|
|
174
|
+
>
|
|
175
|
+
{rows.map(({ row, alpha, offset }) => (
|
|
176
|
+
<span
|
|
177
|
+
key={row}
|
|
178
|
+
className="block whitespace-nowrap text-center text-[15px] font-bold leading-[1.45] tracking-[-0.01em]"
|
|
179
|
+
style={{
|
|
180
|
+
color: `oklch(1 0 0 / ${alpha.toFixed(3)})`,
|
|
181
|
+
// Emboss, both edges scaled by this row's alpha: a dark edge below
|
|
182
|
+
// cuts the letterform into the field, a light edge above lifts its
|
|
183
|
+
// top. The dark edge is kept well under the fill — at parity it
|
|
184
|
+
// ringed every glyph and dragged the type back toward grey, which
|
|
185
|
+
// is the opposite of what the fill is doing.
|
|
186
|
+
textShadow: [
|
|
187
|
+
`0 1px 1px oklch(0 0 0 / ${(alpha * 0.4).toFixed(3)})`,
|
|
188
|
+
`0 -1px 0 oklch(1 0 0 / ${(alpha * 0.4).toFixed(3)})`,
|
|
189
|
+
].join(", "),
|
|
190
|
+
transform: `translateX(${offset}px)`,
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{ghostLine}
|
|
194
|
+
</span>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Clock, Leaf, MapPin, Moon, Sun, Wallet } from "lucide-react";
|
|
4
|
+
import { MenuFilterChips, MenuFilterSummary } from "./menu-filter-chips";
|
|
5
|
+
import { mockFilterChips } from "./menu-mocks";
|
|
6
|
+
|
|
7
|
+
const icons: Record<string, React.ReactNode> = {
|
|
8
|
+
open: <Clock className="h-3 w-3" />,
|
|
9
|
+
price: <Wallet className="h-3 w-3" />,
|
|
10
|
+
terrace: <Sun className="h-3 w-3" />,
|
|
11
|
+
veg: <Leaf className="h-3 w-3" />,
|
|
12
|
+
late: <Moon className="h-3 w-3" />,
|
|
13
|
+
centru: <MapPin className="h-3 w-3" />,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const chips = mockFilterChips.map((chip) => ({ ...chip, icon: icons[chip.key] }));
|
|
17
|
+
|
|
18
|
+
const meta: Meta<typeof MenuFilterChips> = {
|
|
19
|
+
title: "Menu/MenuFilterChips",
|
|
20
|
+
component: MenuFilterChips,
|
|
21
|
+
tags: ["autodocs"],
|
|
22
|
+
decorators: [
|
|
23
|
+
(Story) => (
|
|
24
|
+
<div className="w-[1100px] bg-background p-4">
|
|
25
|
+
<Story />
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
parameters: {
|
|
30
|
+
docs: {
|
|
31
|
+
description: {
|
|
32
|
+
component:
|
|
33
|
+
"Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is pinned opposite the unit label rather than wrapping.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default meta;
|
|
40
|
+
type Story = StoryObj<typeof MenuFilterChips>;
|
|
41
|
+
|
|
42
|
+
function Interactive({ initial }: { initial: typeof chips }) {
|
|
43
|
+
const [state, setState] = useState(initial);
|
|
44
|
+
const active = state.filter((chip) => chip.active).length;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<MenuFilterChips
|
|
49
|
+
chips={state}
|
|
50
|
+
unitLabel="KITCHENS · 247"
|
|
51
|
+
moreLabel="More filters"
|
|
52
|
+
onMore={() => {}}
|
|
53
|
+
ariaLabel="Filter kitchens"
|
|
54
|
+
onToggle={(key) =>
|
|
55
|
+
setState((chips) =>
|
|
56
|
+
chips.map((chip) => (chip.key === key ? { ...chip, active: !chip.active } : chip))
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
/>
|
|
60
|
+
{active > 0 && (
|
|
61
|
+
<MenuFilterSummary
|
|
62
|
+
summary={
|
|
63
|
+
<>
|
|
64
|
+
{active} filters · <b>34 of 247 kitchens</b> match
|
|
65
|
+
</>
|
|
66
|
+
}
|
|
67
|
+
clearLabel="Clear all"
|
|
68
|
+
onClear={() => setState((chips) => chips.map((c) => ({ ...c, active: false })))}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Default: Story = { render: () => <Interactive initial={chips} /> };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A chip that has gone empty.
|
|
79
|
+
*
|
|
80
|
+
* The facets endpoint omits values that would match nothing, so this state
|
|
81
|
+
* cannot arrive from the API — a chip only becomes `empty` client-side, after
|
|
82
|
+
* it was offered and the reader's next filter emptied it. It stays TAPPABLE:
|
|
83
|
+
* pressing it swaps it in and drops whichever active filter conflicts. Disabling
|
|
84
|
+
* it would refuse the reader the thing they asked for and give no reason.
|
|
85
|
+
*/
|
|
86
|
+
export const Dead: Story = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<MenuFilterChips
|
|
89
|
+
chips={chips.map((chip) =>
|
|
90
|
+
chip.key === "late" ? { ...chip, count: 0, empty: true } : chip
|
|
91
|
+
)}
|
|
92
|
+
unitLabel="KITCHENS · 247"
|
|
93
|
+
onToggle={() => {}}
|
|
94
|
+
/>
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const Mobile: Story = {
|
|
99
|
+
render: () => <Interactive initial={chips} />,
|
|
100
|
+
decorators: [
|
|
101
|
+
(Story) => (
|
|
102
|
+
<div className="w-[390px] bg-background p-3">
|
|
103
|
+
<Story />
|
|
104
|
+
</div>
|
|
105
|
+
),
|
|
106
|
+
],
|
|
107
|
+
};
|