@bearmenu/ui 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/chunk-62ES6N32.js +27 -0
  2. package/dist/chunk-AXV5O2IZ.js +58 -0
  3. package/dist/chunk-Q6QH52LJ.js +38 -0
  4. package/dist/chunk-TH6W2PWG.js +213 -0
  5. package/dist/chunk-THOVPZNN.js +37 -0
  6. package/dist/chunk-YS6BHKZO.js +92 -0
  7. package/dist/components/alert.d.ts +2 -2
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +1 -1
  10. package/dist/components/command.d.ts +7 -7
  11. package/dist/components/concept-compare.d.ts +73 -0
  12. package/dist/components/concept-compare.js +162 -0
  13. package/dist/components/event-card.js +1 -1
  14. package/dist/components/event-compact-row.d.ts +1 -1
  15. package/dist/components/event-compact-row.js +1 -1
  16. package/dist/components/event-detail-panel.d.ts +171 -25
  17. package/dist/components/event-detail-panel.js +471 -174
  18. package/dist/components/event-list-row-wide.js +1 -1
  19. package/dist/components/event-list-row.js +1 -1
  20. package/dist/components/hub-surface.d.ts +27 -0
  21. package/dist/components/hub-surface.js +20 -0
  22. package/dist/components/menu-family-card.d.ts +59 -0
  23. package/dist/components/menu-family-card.js +121 -0
  24. package/dist/components/menu-field.d.ts +57 -0
  25. package/dist/components/menu-field.js +3 -0
  26. package/dist/components/menu-hub-hero.d.ts +65 -0
  27. package/dist/components/menu-hub-hero.js +103 -0
  28. package/dist/components/menu-intent-tiles.d.ts +31 -0
  29. package/dist/components/menu-intent-tiles.js +50 -0
  30. package/dist/components/menu-item.d.ts +127 -0
  31. package/dist/components/menu-item.js +5 -0
  32. package/dist/components/menu-mocks.d.ts +55 -0
  33. package/dist/components/menu-mocks.js +379 -0
  34. package/dist/components/menu-types.d.ts +245 -0
  35. package/dist/components/menu-types.js +1 -0
  36. package/dist/components/motion-icon-set.js +15 -3
  37. package/dist/components/open-now-list.d.ts +40 -0
  38. package/dist/components/open-now-list.js +69 -0
  39. package/dist/components/place-types.d.ts +112 -1
  40. package/dist/components/ranked-venue-list.d.ts +49 -0
  41. package/dist/components/ranked-venue-list.js +87 -0
  42. package/dist/components/signature-chip.d.ts +37 -0
  43. package/dist/components/signature-chip.js +4 -0
  44. package/dist/components/signature-line-list.d.ts +45 -0
  45. package/dist/components/signature-line-list.js +99 -0
  46. package/dist/components/snap-rail.d.ts +24 -4
  47. package/dist/components/snap-rail.js +66 -8
  48. package/dist/components/spinner.d.ts +2 -2
  49. package/dist/components/stat-banner.d.ts +47 -0
  50. package/dist/components/stat-banner.js +61 -0
  51. package/dist/components/tag.d.ts +2 -2
  52. package/dist/components/text.d.ts +3 -3
  53. package/dist/components/venue-busyness-strip.d.ts +35 -0
  54. package/dist/components/venue-busyness-strip.js +37 -0
  55. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  56. package/dist/components/venue-grade-dimensions.js +3 -0
  57. package/dist/components/venue-quotes.d.ts +32 -0
  58. package/dist/components/venue-quotes.js +3 -0
  59. package/dist/components/venue-signature-block.d.ts +39 -0
  60. package/dist/components/venue-signature-block.js +6 -0
  61. package/dist/components/venue-spotlight.d.ts +74 -0
  62. package/dist/components/venue-spotlight.js +138 -0
  63. package/dist/components/venue-wall.d.ts +41 -0
  64. package/dist/components/venue-wall.js +118 -0
  65. package/dist/components/weekday-theme-banner.d.ts +70 -0
  66. package/dist/components/weekday-theme-banner.js +127 -0
  67. package/package.json +1 -1
  68. package/src/components/colour-dictionary.stories.tsx +227 -0
  69. package/src/components/concept-compare.stories.tsx +104 -0
  70. package/src/components/concept-compare.tsx +276 -0
  71. package/src/components/event-compact-row.tsx +1 -1
  72. package/src/components/event-detail-panel.stories.tsx +205 -97
  73. package/src/components/event-detail-panel.test.tsx +181 -0
  74. package/src/components/event-detail-panel.tsx +703 -185
  75. package/src/components/hub-surface.stories.tsx +68 -0
  76. package/src/components/hub-surface.tsx +40 -0
  77. package/src/components/menu-family-card.stories.tsx +112 -0
  78. package/src/components/menu-family-card.tsx +211 -0
  79. package/src/components/menu-field.stories.tsx +74 -0
  80. package/src/components/menu-field.tsx +199 -0
  81. package/src/components/menu-hub-hero.stories.tsx +92 -0
  82. package/src/components/menu-hub-hero.tsx +187 -0
  83. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  84. package/src/components/menu-intent-tiles.tsx +76 -0
  85. package/src/components/menu-item.tsx +416 -0
  86. package/src/components/menu-mocks.ts +417 -0
  87. package/src/components/menu-types.ts +259 -0
  88. package/src/components/motion-icon-set.tsx +30 -3
  89. package/src/components/open-now-list.stories.tsx +76 -0
  90. package/src/components/open-now-list.tsx +127 -0
  91. package/src/components/place-types.ts +112 -0
  92. package/src/components/ranked-venue-list.stories.tsx +80 -0
  93. package/src/components/ranked-venue-list.tsx +177 -0
  94. package/src/components/signature-chip.tsx +77 -0
  95. package/src/components/signature-line-list.stories.tsx +85 -0
  96. package/src/components/signature-line-list.tsx +136 -0
  97. package/src/components/snap-rail.tsx +90 -9
  98. package/src/components/stat-banner.stories.tsx +100 -0
  99. package/src/components/stat-banner.tsx +117 -0
  100. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  101. package/src/components/venue-busyness-strip.tsx +64 -0
  102. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  103. package/src/components/venue-grade-dimensions.tsx +91 -0
  104. package/src/components/venue-quotes.stories.tsx +59 -0
  105. package/src/components/venue-quotes.tsx +64 -0
  106. package/src/components/venue-signature-block.stories.tsx +68 -0
  107. package/src/components/venue-signature-block.tsx +71 -0
  108. package/src/components/venue-spotlight.stories.tsx +141 -0
  109. package/src/components/venue-spotlight.tsx +236 -0
  110. package/src/components/venue-wall.stories.tsx +78 -0
  111. package/src/components/venue-wall.tsx +185 -0
  112. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  113. package/src/components/weekday-theme-banner.tsx +231 -0
@@ -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,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ArrowLeftRight, UtensilsCrossed } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { MenuHubHero } from "./menu-hub-hero";
5
+
6
+ const meta: Meta<typeof MenuHubHero> = {
7
+ title: "Menu/MenuHubHero",
8
+ component: MenuHubHero,
9
+ tags: ["autodocs"],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[1100px] bg-background p-4">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "The claim that opens a menu hub, proven by live figures. A statement rather than a count, with the catalogue's real numbers under it and one proof line demonstrating what a catalogue can say that a single menu cannot. The ground is a texture rather than a photograph because item photography is generated and one venue's photo behind a claim about every venue argues the wrong thing — callers with a real licensed venue image can pass `backgroundUrl`.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof MenuHubHero>;
29
+
30
+ export const Default: Story = {
31
+ args: {
32
+ kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
33
+ kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
34
+ headline: "Nobody should have to walk in blind.",
35
+ body: "We read every menu in the city — what each kitchen serves, what it costs, how big the portion is, and which plates the reviews keep naming. Decide before you sit down.",
36
+ figures: [
37
+ { value: "19,062", label: "dishes" },
38
+ { value: "26,078", label: "drinks" },
39
+ { value: "397", label: "menus read" },
40
+ { value: "38", label: "cuisines" },
41
+ ],
42
+ proof: (
43
+ <span>
44
+ A margherita: <b className="font-mono font-medium">87 kitchens make one, 10 to 120 lei</b>
45
+ </span>
46
+ ),
47
+ proofIcon: <ArrowLeftRight className="h-3.5 w-3.5" />,
48
+ action: (
49
+ <Button size="sm" variant="secondary">
50
+ See the spread
51
+ </Button>
52
+ ),
53
+ },
54
+ };
55
+
56
+ /** The drinks tab: same component, different view model and copy. */
57
+ export const DrinksTab: Story = {
58
+ args: {
59
+ kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
60
+ kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
61
+ headline: "The same negroni, across seventy-four bars.",
62
+ body: "Twenty-six thousand drinks, matched into concepts so the price comparison is real.",
63
+ figures: [
64
+ { value: "26,078", label: "drinks" },
65
+ { value: "397", label: "menus read" },
66
+ { value: "74", label: "bars pour a negroni" },
67
+ ],
68
+ },
69
+ };
70
+
71
+ /** No figures, no proof — the hero degrades to a claim and nothing else. */
72
+ export const ClaimOnly: Story = {
73
+ args: { headline: "Nobody should have to walk in blind." },
74
+ };
75
+
76
+ export const Mobile: Story = {
77
+ args: {
78
+ kicker: "397 MENUS READ IN FULL",
79
+ headline: "Nobody should have to walk in blind.",
80
+ figures: [
81
+ { value: "19,062", label: "dishes" },
82
+ { value: "397", label: "menus read" },
83
+ ],
84
+ },
85
+ decorators: [
86
+ (Story) => (
87
+ <div className="w-[390px] bg-background p-3">
88
+ <Story />
89
+ </div>
90
+ ),
91
+ ],
92
+ };
@@ -0,0 +1,187 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * MenuHubHero — the claim that opens a menu hub, proven by live figures.
5
+ *
6
+ * A statement rather than a count ("Nobody should have to walk in blind"),
7
+ * over a dark ground, with the catalogue's real numbers under it and one proof
8
+ * line that demonstrates what the catalogue can do that a single menu cannot.
9
+ *
10
+ * ## Why the ground is a texture and not a photograph
11
+ *
12
+ * Menu-item photography is AI-generated and never shown, and one venue
13
+ * photograph behind a claim about every venue in the city argues the wrong
14
+ * thing. The tile grid reads as "many menus" without being any of them.
15
+ * Callers that DO have a real, licensed image for the slot can pass
16
+ * `backgroundUrl` — venue photography, never a plate.
17
+ *
18
+ * ## Figures
19
+ *
20
+ * `figures` is a list, not four fixed slots: the drinks tab has different ones
21
+ * and the count changes when a number turns out to have nothing behind it.
22
+ * Every value is a pre-formatted string because thousands separators are
23
+ * locale-dependent and the DS owns no locale.
24
+ *
25
+ * Server-safe.
26
+ */
27
+
28
+ export type MenuHubHeroFigure = {
29
+ /** Pre-formatted, e.g. "19,062". */
30
+ value: string;
31
+ label: string;
32
+ };
33
+
34
+ export type MenuHubHeroProps = {
35
+ /** Small caps line above the headline, e.g. "397 MENUS IN CLUJ-NAPOCA, READ IN FULL". */
36
+ kicker?: string;
37
+ /** Icon for the kicker, resolved by the caller. */
38
+ kickerIcon?: React.ReactNode;
39
+ headline: string;
40
+ body?: string;
41
+ figures?: MenuHubHeroFigure[];
42
+ /**
43
+ * The proof line — one demonstrated fact, e.g. "A margherita: 87 kitchens
44
+ * make one, 10 to 120 lei". Composed by the caller; clause order is
45
+ * locale-dependent.
46
+ */
47
+ proof?: React.ReactNode;
48
+ proofIcon?: React.ReactNode;
49
+ /** The action beside the proof line. Pass a rendered link or button. */
50
+ action?: React.ReactNode;
51
+ /** Real, licensed photograph for the ground. Venue imagery only — see above. */
52
+ backgroundUrl?: string;
53
+ /**
54
+ * Tiles for the mosaic ground — generic, cinematic, licensed stills, served
55
+ * from the consumer's own origin.
56
+ *
57
+ * A mosaic rather than one photograph because the hero's claim is about EVERY
58
+ * kitchen in the city: a single image makes it about one of them. Given fewer
59
+ * tiles than cells the list repeats, so a caller can ship six and still fill
60
+ * a 1440px hero.
61
+ */
62
+ tileUrls?: string[];
63
+ className?: string;
64
+ };
65
+
66
+ /** Enough cells to fill the widest hero without the grid reading as a pattern. */
67
+ const TEXTURE_CELLS = 28;
68
+
69
+ export function MenuHubHero({
70
+ kicker,
71
+ kickerIcon,
72
+ headline,
73
+ body,
74
+ figures,
75
+ proof,
76
+ proofIcon,
77
+ action,
78
+ backgroundUrl,
79
+ tileUrls,
80
+ className,
81
+ }: MenuHubHeroProps) {
82
+ return (
83
+ <div
84
+ className={cn(
85
+ "relative flex min-h-[260px] overflow-hidden rounded-[20px] bg-[oklch(0.21_0.012_55)] lg:min-h-[440px]",
86
+ className
87
+ )}
88
+ >
89
+ {backgroundUrl ? (
90
+ // eslint-disable-next-line @next/next/no-img-element
91
+ <img
92
+ src={backgroundUrl}
93
+ alt=""
94
+ aria-hidden
95
+ className="absolute inset-0 h-full w-full object-cover opacity-[0.42]"
96
+ />
97
+ ) : (
98
+ <div
99
+ aria-hidden
100
+ className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-7"
101
+ >
102
+ {Array.from({ length: TEXTURE_CELLS }, (_, cell) => {
103
+ const url = tileUrls?.length ? tileUrls[cell % tileUrls.length] : undefined;
104
+
105
+ return url ? (
106
+ // eslint-disable-next-line @next/next/no-img-element
107
+ <img
108
+ key={cell}
109
+ src={url}
110
+ alt=""
111
+ loading="lazy"
112
+ decoding="async"
113
+ className="h-full w-full object-cover"
114
+ />
115
+ ) : (
116
+ <div
117
+ key={cell}
118
+ className="bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]"
119
+ />
120
+ );
121
+ })}
122
+ </div>
123
+ )}
124
+
125
+ {/*
126
+ Heavier over a photo mosaic than it was over flat texture: twelve
127
+ cinematic stills at full strength turn a claim into a collage and the
128
+ headline stops being the loudest thing on the banner. This keeps the
129
+ depth and gives the type its contrast back.
130
+ */}
131
+ <div
132
+ aria-hidden
133
+ className={cn(
134
+ "absolute inset-0",
135
+ tileUrls?.length || backgroundUrl
136
+ ? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.88)_52%,oklch(0.16_0.012_55_/_0.72)_100%)]"
137
+ : "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
+ )}
139
+ />
140
+
141
+ <div className="relative z-[1] flex w-full flex-col justify-center gap-3 p-5 text-white lg:gap-4 lg:px-12 lg:py-11">
142
+ {kicker && (
143
+ <span className="inline-flex w-max items-center gap-[7px] rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
144
+ {kickerIcon}
145
+ {kicker}
146
+ </span>
147
+ )}
148
+
149
+ <h1 className="max-w-[640px] font-serif text-[32px] font-semibold leading-[1.04] tracking-[-0.025em] lg:text-[50px]">
150
+ {headline}
151
+ </h1>
152
+
153
+ {body && (
154
+ <p className="hidden max-w-[540px] text-sm leading-[1.6] opacity-[0.84] lg:block lg:text-[15.5px]">
155
+ {body}
156
+ </p>
157
+ )}
158
+
159
+ {figures && figures.length > 0 && (
160
+ // Two by two on the phone: four figures in one row wrapped to three and an orphan.
161
+ <div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-3 lg:flex lg:flex-wrap lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
162
+ {figures.map((figure) => (
163
+ <div key={figure.label}>
164
+ <div className="text-2xl font-black leading-none tracking-[-0.03em] tabular-nums lg:text-[32px]">
165
+ {figure.value}
166
+ </div>
167
+ <div className="mt-[5px] text-xs opacity-[0.72]">{figure.label}</div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ {(proof || action) && (
174
+ <div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
175
+ {proof && (
176
+ <span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/10 px-4 py-2 text-[13px] backdrop-blur-[6px]">
177
+ {proofIcon}
178
+ {proof}
179
+ </span>
180
+ )}
181
+ {action}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Beef, Coffee, Leaf, MapPin, Pizza, UtensilsCrossed } from "lucide-react";
3
+ import { MenuIntentTiles } from "./menu-intent-tiles";
4
+ import { mockIntentTiles } from "./menu-mocks";
5
+
6
+ const icons = [Pizza, Beef, Coffee, UtensilsCrossed, Leaf, MapPin];
7
+ const tiles = mockIntentTiles.map((tile, index) => {
8
+ const Icon = icons[index];
9
+ return { ...tile, icon: <Icon /> };
10
+ });
11
+
12
+ const meta: Meta<typeof MenuIntentTiles> = {
13
+ title: "Menu/MenuIntentTiles",
14
+ component: MenuIntentTiles,
15
+ tags: ["autodocs"],
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[1100px] bg-background p-4">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "The sentence people arrive with, as six coloured tiles. An intent is a family plus a constraint — Italian *tonight*, burgers, coffee — and each tile is a destination, so `href` is required on the view model: a category page that does not exist is filtered out by the caller rather than rendered as a tile linking nowhere. Roughly thirty-two of thirty-eight cuisines have a page, so that is the normal case, not the edge one.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof MenuIntentTiles>;
35
+
36
+ export const Default: Story = { args: { tiles } };
37
+
38
+ /** Two-up on a phone, six-up from `lg`. */
39
+ export const Mobile: Story = {
40
+ args: { tiles },
41
+ decorators: [
42
+ (Story) => (
43
+ <div className="w-[390px] bg-background p-3">
44
+ <Story />
45
+ </div>
46
+ ),
47
+ ],
48
+ };
49
+
50
+ /** Without icons the tiles still read — the watermark is recognition, not information. */
51
+ export const WithoutIcons: Story = { args: { tiles: mockIntentTiles } };
52
+
53
+ /** Fewer tiles than a full row: the grid does not stretch them. */
54
+ export const Partial: Story = { args: { tiles: tiles.slice(0, 3) } };
@@ -0,0 +1,76 @@
1
+ import { ChevronRight } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { MenuIntentTile, MenuLinkProps, MenuLinkComponent } from "./menu-types";
4
+
5
+ /**
6
+ * MenuIntentTiles — the sentence people arrive with, as six coloured tiles.
7
+ *
8
+ * An intent is a family plus a constraint: Italian *tonight*, burgers, coffee.
9
+ * Each tile is a destination, so `href` is required on the view model — a
10
+ * category page that does not exist is filtered out by the caller rather than
11
+ * rendered as a tile that links nowhere. Roughly thirty-two of thirty-eight
12
+ * cuisines have a page, so that case is normal, not exceptional.
13
+ *
14
+ * The icon is a watermark bleeding off the bottom-right corner at low opacity,
15
+ * not a badge: it gives the tile a silhouette to be recognised by in a rail
16
+ * without competing with the label. It is `aria-hidden` — the label already
17
+ * says what the tile is.
18
+ *
19
+ * Server-safe.
20
+ */
21
+
22
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
23
+ export type MenuIntentTilesLinkProps = MenuLinkProps;
24
+
25
+ function DefaultLink({ href, ...props }: MenuIntentTilesLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type MenuIntentTilesProps = {
30
+ tiles: MenuIntentTile[];
31
+ linkComponent?: MenuLinkComponent;
32
+ className?: string;
33
+ };
34
+
35
+ export function MenuIntentTiles({
36
+ tiles,
37
+ linkComponent: LinkComponent = DefaultLink,
38
+ className,
39
+ }: MenuIntentTilesProps) {
40
+ return (
41
+ <div
42
+ className={cn(
43
+ "grid grid-cols-2 gap-3 lg:grid-cols-6 lg:gap-3.5",
44
+ className
45
+ )}
46
+ >
47
+ {tiles.map((tile) => (
48
+ <LinkComponent
49
+ key={tile.slug}
50
+ href={tile.href}
51
+ className="relative flex min-h-[110px] flex-col overflow-hidden rounded-2xl bg-muted p-3.5 text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 lg:min-h-[124px] lg:px-3.5 lg:pb-3.5 lg:pt-[15px]"
52
+ style={tile.gradient ? { backgroundImage: tile.gradient } : undefined}
53
+ >
54
+ {tile.icon && (
55
+ <span
56
+ aria-hidden
57
+ className="pointer-events-none absolute -bottom-2.5 -right-1.5 z-0 opacity-[0.22] [&>svg]:h-[62px] [&>svg]:w-[62px]"
58
+ >
59
+ {tile.icon}
60
+ </span>
61
+ )}
62
+
63
+ <span className="relative z-[1] flex h-full flex-col">
64
+ <span className="text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em]">
65
+ {tile.label}
66
+ </span>
67
+ {tile.caption && (
68
+ <span className="mt-[3px] text-[11.5px] leading-[1.35]">{tile.caption}</span>
69
+ )}
70
+ <ChevronRight className="mt-auto h-4 w-4 opacity-85" aria-hidden />
71
+ </span>
72
+ </LinkComponent>
73
+ ))}
74
+ </div>
75
+ );
76
+ }