@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.
Files changed (206) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/chunk-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,443 @@
1
+ import { ArrowDown, MapPin, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
4
+ import { Separator } from "./separator";
5
+ import { Skeleton } from "./skeleton";
6
+ import { ConceptCompareRail } from "./concept-compare-rail";
7
+ import type { MenuConceptPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
8
+
9
+ /**
10
+ * ConceptCompare — one dish or drink across the kitchens that make it, under
11
+ * whatever name each of them uses. No surfaces at all.
12
+ *
13
+ * The keystone module: a single menu can list papanași, only a catalogue can
14
+ * say that forty-five kitchens make them under eight names between 29 and 52
15
+ * lei. Each kitchen's own wording is the evidence that these are matched rows
16
+ * rather than one row repeated.
17
+ *
18
+ * ## The card IS the rooms
19
+ *
20
+ * Every panel is gone. Each room is a VENUE photograph — real and licensed
21
+ * where an item's would be generated — with the kitchen's line floating over
22
+ * it, and the three readable things sit on glass: the identity in a band
23
+ * across the top, each kitchen's line over its own photograph, the foot along
24
+ * the bottom.
25
+ *
26
+ * ## Read across on desktop, one at a time on the phone
27
+ *
28
+ * From `lg` the rooms stand side by side — four kitchens, each with its own
29
+ * name for the dish and its own price, so comparing is a horizontal glance —
30
+ * and the head and foot are ABSOLUTE glass bands over them, so the photographs
31
+ * run the full height of the card beneath both. The head's glass fades out
32
+ * downward on the same curve as its mask; the foot's fades out upward the same
33
+ * way. It was the ink stopping short of the mask that drew a line where a band
34
+ * ended.
35
+ *
36
+ * Below `lg` four rooms in a 2×2 was too much happening in one card, so the
37
+ * rooms become a `SnapRail`: one room per swipe, page dots under it, the head
38
+ * and foot static on the card's ground above and below. The dots are the
39
+ * rail's own — the same object the hub's other rails use.
40
+ *
41
+ * ## The dark overlay, and the hover that lifts it
42
+ *
43
+ * Four busy interiors are four competing subjects, and type over them had
44
+ * nothing to sit on but its own local scrim — the eye could not settle. Every
45
+ * photograph carries a uniform dark overlay so the words lead and the picture
46
+ * supports; hovering a room lifts the overlay off THAT room only, so the reader
47
+ * can look at the place they are about to open.
48
+ *
49
+ * ## Light and dark
50
+ *
51
+ * There is nothing in it to theme. Photographs and their scrims are identical
52
+ * in both, because text over an image needs the same treatment either way —
53
+ * the content is `.dark`-scoped for exactly that. What adapts is the card's
54
+ * EDGE: a soft shadow and hairline on light, a deeper shadow and a light
55
+ * hairline on dark. The edge lives on the outer wrapper, outside the scope.
56
+ *
57
+ * ## No room here is a room that does not serve it
58
+ *
59
+ * The earlier card led with an AI-generated picture of the dish, and a
60
+ * generated image at the head of a block whose whole argument is "we counted
61
+ * real menus" undercut the argument. The rooms are real.
62
+ */
63
+
64
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
65
+ export type ConceptCompareLinkProps = MenuLinkProps;
66
+
67
+ function DefaultLink({ href, ...props }: ConceptCompareLinkProps) {
68
+ return <a href={href} {...props} />;
69
+ }
70
+
71
+ export type ConceptCompareFigure = {
72
+ /** Pre-formatted, e.g. "44" or "29–52". */
73
+ value: string;
74
+ label: string;
75
+ /** Draws the value in amber — the spread, which is the number the card is about. */
76
+ accent?: boolean;
77
+ };
78
+
79
+ export type ConceptCompareLabels = {
80
+ /** Small caps kicker, e.g. "DISH CONCEPT". */
81
+ kind: string;
82
+ /** The badge on the lowest-priced room, e.g. "CHEAPEST". */
83
+ cheapest: string;
84
+ /** The badge on every other room, e.g. "#2". Defaults to `#${rank}`. */
85
+ rank?: (rank: number) => string;
86
+ /** Beside each price, e.g. "12 mentions". Pluralised by the caller. */
87
+ mentions: (count: number) => string;
88
+ /** Accessible name for a room's link, e.g. "Papanași at Casa Maramureșeană". */
89
+ openRoom: (alias: string, venue: string) => string;
90
+ };
91
+
92
+ export type ConceptCompareProps = {
93
+ concept: MenuConceptPreview;
94
+ labels: ConceptCompareLabels;
95
+ /** One line under the title, e.g. "45 kitchens make it, and they do not agree on the name". */
96
+ lede?: string;
97
+ formatPrice: (amount: number) => string;
98
+ figures?: ConceptCompareFigure[];
99
+ /** Destination per room, keyed by place slug. A room without one is inert. */
100
+ hrefs?: Record<string, string>;
101
+ /** The provenance note in the foot. */
102
+ footnote?: string;
103
+ /** The action in the foot — pass a rendered link or button. */
104
+ actions?: React.ReactNode;
105
+ linkComponent?: MenuLinkComponent;
106
+ className?: string;
107
+ };
108
+
109
+ /** Four rooms across on desktop; the caller decides how many it sends. */
110
+ const MAX_ROOMS = 4;
111
+
112
+ /** The venue's first letter, for a room with no photograph. */
113
+ const initialOf = (name: string) => (name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
114
+
115
+ /** The card's ground is `PhotoGround`'s — the phone's surface for head and foot too. */
116
+ const GROUND = PHOTO_GROUND;
117
+
118
+ /*
119
+ * The layers, bottom to top: the photograph, a DARK OVERLAY on it, the room's
120
+ * own scrim under its type, and the two glass bands. Written out in full rather
121
+ * than composed from a shared colour string: Tailwind emits only the classes it
122
+ * can read verbatim at build time, and a template literal is invisible to it —
123
+ * the class would be in the DOM and the style would not exist.
124
+ *
125
+ * Every layer shares one literal, `oklch(0.16 0.012 55)`, so they read as one
126
+ * shadow. Not `var(--scrim)`: that token is the app's, not this package's.
127
+ */
128
+
129
+ /** `PhotoGround`'s overlay, plus the hover that lifts it off THIS room only. */
130
+ const PHOTO_OVERLAY_HOVER =
131
+ "transition-opacity duration-300 group-hover/room:opacity-0 motion-reduce:transition-none";
132
+
133
+ /** Each room's own scrim, rising from its foot under the type. */
134
+ const ROOM_SCRIM =
135
+ "absolute inset-0 bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0)_30%,oklch(0.16_0.012_55_/_0.55)_58%,oklch(0.16_0.012_55_/_0.9)_100%)]";
136
+
137
+ /**
138
+ * The head band's glass. Mask and ink both run the band's full height and go
139
+ * to ZERO at its underside on the same curve — a soft edge, not a line.
140
+ */
141
+ const HEAD_GLASS =
142
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg before:[mask-image:linear-gradient(to_bottom,black_35%,transparent_100%)] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.8)_0%,oklch(0.16_0.012_55_/_0.6)_35%,oklch(0.16_0.012_55_/_0.22)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
143
+
144
+ /* The foot band's glass lives in `concept-compare-rail.tsx`, with the foot. */
145
+
146
+ /**
147
+ * Desktop column templates by room count — literal, for the scanner. The phone
148
+ * ignores these: there the rail lays the rooms out.
149
+ */
150
+ const COLUMNS: Record<number, string> = {
151
+ 1: "lg:grid-cols-1",
152
+ 2: "lg:grid-cols-2",
153
+ 3: "lg:grid-cols-3",
154
+ 4: "lg:grid-cols-4",
155
+ };
156
+
157
+ /**
158
+ * How far a room's text sits above the card's foot on desktop, where the foot
159
+ * is an absolute band over the rooms. The band is one line of prose and a
160
+ * button, ~80px; the padding clears it with room to spare at `lg`, where the
161
+ * prose can wrap to two lines.
162
+ */
163
+ const DESKTOP_FOOT_CLEARANCE = "lg:pb-[104px]";
164
+
165
+ export function ConceptCompare({
166
+ concept,
167
+ labels,
168
+ lede,
169
+ formatPrice,
170
+ figures,
171
+ hrefs,
172
+ footnote,
173
+ actions,
174
+ linkComponent: LinkComponent = DefaultLink,
175
+ className,
176
+ }: ConceptCompareProps) {
177
+ const rooms = (concept.rows ?? []).slice(0, MAX_ROOMS);
178
+ const count = rooms.length;
179
+ if (count === 0) return null;
180
+
181
+ // Derived, not passed: the cheapest room is a fact about THIS list, and a
182
+ // caller computing it separately could disagree with what is rendered.
183
+ const cheapest = rooms.reduce<number | undefined>(
184
+ (lowest, room) => (lowest === undefined || room.price < lowest ? room.price : lowest),
185
+ undefined
186
+ );
187
+ const rankOf = labels.rank ?? ((rank: number) => `#${rank}`);
188
+ const hasFoot = Boolean(footnote || actions);
189
+
190
+ return (
191
+ <div
192
+ className={cn(
193
+ /* The edge, OUTSIDE the dark scope — the one thing about this card
194
+ that belongs to the page it sits on. */
195
+ "overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
196
+ className
197
+ )}
198
+ >
199
+ <div className={cn("dark relative isolate flex flex-col text-foreground", GROUND)}>
200
+ {/* ── The identity ─────────────────────────────────────────────── */}
201
+ {/* A `div`, not `<header>`. Outside `<article>`/`<section>` a `<header>`
202
+ is the `banner` landmark and a `<footer>` is `contentinfo`, so each
203
+ card would have announced itself as the page's own header and
204
+ footer to a screen reader. */}
205
+ <div
206
+ className={cn(
207
+ "relative isolate flex flex-col gap-3 px-4 pb-5 pt-4",
208
+ "lg:absolute lg:inset-x-0 lg:top-0 lg:z-10 lg:flex-row lg:items-start lg:justify-between lg:gap-6 lg:px-5 lg:pb-12 lg:pt-5",
209
+ HEAD_GLASS
210
+ )}
211
+ >
212
+ <div className="min-w-0">
213
+ <span className="block text-[10.5px] font-extrabold uppercase tracking-[0.13em] text-white/70">
214
+ {labels.kind}
215
+ </span>
216
+ <h3 className="mt-1 font-serif text-[28px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
217
+ {concept.title}
218
+ </h3>
219
+ {lede && <p className="mt-1 text-[13px] leading-snug text-white/75">{lede}</p>}
220
+ </div>
221
+
222
+ {figures && figures.length > 0 && (
223
+ <dl className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 lg:gap-x-6">
224
+ {figures.map((figure) => (
225
+ <div key={figure.label} className="flex flex-col items-start lg:items-center">
226
+ <dd
227
+ className={cn(
228
+ "text-[22px] font-bold leading-none tabular-nums tracking-[-0.02em]",
229
+ figure.accent ? "text-[oklch(0.82_0.15_85)]" : "text-white"
230
+ )}
231
+ >
232
+ {figure.value}
233
+ </dd>
234
+ <dt className="mt-1 text-[11px] text-white/65">{figure.label}</dt>
235
+ </div>
236
+ ))}
237
+ </dl>
238
+ )}
239
+ </div>
240
+
241
+ {/* ── The rooms: a rail on the phone, a row from `lg` ──────────── */}
242
+ {/* The rail also owns the phone's backdrop — the card's ground is the
243
+ ACTIVE room's photograph, cross-fading on swipe. That is the one
244
+ piece of state in this card, so it is the one client boundary, and
245
+ it takes only what can cross one: URLs, initials, rendered rooms. */}
246
+ <ConceptCompareRail
247
+ backdrops={rooms.map((room) => ({
248
+ src: room.place_thumbnail_url,
249
+ initial: initialOf(room.place_name),
250
+ }))}
251
+ railClassName={cn(
252
+ "lg:grid lg:gap-px lg:overflow-visible lg:[&>*]:w-auto",
253
+ COLUMNS[count] ?? COLUMNS[4]
254
+ )}
255
+ /* The foot's CONTENT; the rail draws the band, because on the phone
256
+ the page dots belong inside its glass and the dots are the rail's
257
+ state. */
258
+ foot={
259
+ hasFoot ? (
260
+ <>
261
+ {footnote && (
262
+ <p className="min-w-0 flex-1 text-[12px] leading-relaxed text-white/70">
263
+ {footnote}
264
+ </p>
265
+ )}
266
+ {actions && (
267
+ <div className="flex shrink-0 [&>*]:w-full lg:[&>*]:w-auto">{actions}</div>
268
+ )}
269
+ </>
270
+ ) : undefined
271
+ }
272
+ >
273
+ {rooms.map((room, index) => {
274
+ const href = hrefs?.[room.place_slug];
275
+ const isCheapest = room.price === cheapest;
276
+ const roomClass = cn(
277
+ /* Transparent on the phone — the card behind it is the room's
278
+ photograph — with only its scrim and its line. A photograph of
279
+ its own from `lg`, where four stand side by side. */
280
+ "group/room relative isolate flex aspect-[4/3] flex-col justify-end overflow-hidden p-4",
281
+ "lg:aspect-auto lg:min-h-[440px] lg:p-4",
282
+ DESKTOP_FOOT_CLEARANCE
283
+ );
284
+ const body = (
285
+ <>
286
+ {/* Behind the type: the room's own photograph and its overlay
287
+ (desktop only — on the phone the card's ground is the
288
+ photograph), and the scrim (both). */}
289
+ <span aria-hidden className="absolute inset-0 -z-10">
290
+ <PhotoGround
291
+ className="hidden lg:block"
292
+ photos={[room.place_thumbnail_url]}
293
+ initials={initialOf(room.place_name)}
294
+ mosaicClassName="grid-cols-1"
295
+ overlay
296
+ overlayClassName={PHOTO_OVERLAY_HOVER}
297
+ />
298
+ <span className={ROOM_SCRIM} />
299
+ </span>
300
+
301
+ <span
302
+ className={cn(
303
+ "inline-flex h-[22px] w-max items-center gap-1 rounded-full px-2 text-[10px] font-extrabold uppercase tracking-[0.08em]",
304
+ isCheapest
305
+ ? "bg-success text-white"
306
+ : "bg-white/15 text-white/85 backdrop-blur-[2px]"
307
+ )}
308
+ >
309
+ {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
310
+ {isCheapest ? labels.cheapest : rankOf(index + 1)}
311
+ </span>
312
+
313
+ {/* Name and rating on one line: the pin leads, the name takes
314
+ what is left, the star sits at the end. */}
315
+ <span className="mt-2.5 flex items-center gap-1.5 text-white">
316
+ <MapPin className="h-3.5 w-3.5 shrink-0 text-white/70" aria-hidden />
317
+ <b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
318
+ {room.place_name}
319
+ </b>
320
+ {room.rating !== undefined && (
321
+ <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
322
+ <Star className="h-3 w-3 shrink-0" aria-hidden />
323
+ <span className="tabular-nums">{room.rating}</span>
324
+ </span>
325
+ )}
326
+ </span>
327
+ {room.place_area && (
328
+ <span className="mt-0.5 block text-[12px] text-white/70">{room.place_area}</span>
329
+ )}
330
+
331
+ {/* The kitchen's own wording — the evidence that these are
332
+ matched rows rather than one row repeated. */}
333
+ <span className="mt-2 block truncate font-serif text-[14px] italic text-white/90">
334
+ {room.alias}
335
+ </span>
336
+
337
+ {/* The DS rule. `via-white/20` because the dark theme's `--border`
338
+ is a 0.32-lightness grey that vanishes on inked photography. */}
339
+ <Separator className="my-2.5 via-white/20" />
340
+
341
+ <span className="flex items-baseline gap-2">
342
+ <b
343
+ className={cn(
344
+ "text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
345
+ isCheapest ? "text-success" : "text-white"
346
+ )}
347
+ >
348
+ {formatPrice(room.price)}
349
+ </b>
350
+ {room.mentions !== undefined && room.mentions > 0 && (
351
+ <span className="text-[11.5px] text-white/65">
352
+ {labels.mentions(room.mentions)}
353
+ </span>
354
+ )}
355
+ </span>
356
+ </>
357
+ );
358
+
359
+ return href ? (
360
+ <LinkComponent
361
+ key={room.place_slug}
362
+ href={href}
363
+ aria-label={labels.openRoom(room.alias, room.place_name)}
364
+ className={cn(
365
+ roomClass,
366
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
367
+ )}
368
+ >
369
+ {body}
370
+ </LinkComponent>
371
+ ) : (
372
+ <div key={room.place_slug} className={roomClass}>
373
+ {body}
374
+ </div>
375
+ );
376
+ })}
377
+ </ConceptCompareRail>
378
+ </div>
379
+ </div>
380
+ );
381
+ }
382
+
383
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * The card's own loading state, at the card's footprint.
387
+ *
388
+ * The ground is the card's own dark surface, so the placeholder is already
389
+ * what the photographs will land on and nothing flashes from paper to ink.
390
+ * The phone shape is one room tall with the head above and the foot below;
391
+ * from `lg` it is the four-room row with the head and foot laid over it.
392
+ */
393
+ export function ConceptCompareSkeleton({ className }: { className?: string }) {
394
+ return (
395
+ <div
396
+ className={cn(
397
+ "overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
398
+ className
399
+ )}
400
+ aria-hidden
401
+ >
402
+ <div className={cn("dark relative flex flex-col text-foreground", GROUND)}>
403
+ <div className="flex flex-col gap-3 px-4 pb-5 pt-4 lg:absolute lg:inset-x-0 lg:top-0 lg:z-10 lg:flex-row lg:justify-between lg:px-5 lg:pt-5">
404
+ <div>
405
+ <Skeleton className="h-2.5 w-[90px] bg-white/15" />
406
+ <Skeleton className="mt-2 h-8 w-[160px] bg-white/15" />
407
+ <Skeleton className="mt-2 h-3.5 w-[240px] bg-white/15" />
408
+ </div>
409
+ <div className="flex gap-5">
410
+ {Array.from({ length: 4 }, (_, index) => (
411
+ <div key={index} className="flex flex-col items-start gap-1.5 lg:items-center">
412
+ <Skeleton className="h-6 w-8 bg-white/15" />
413
+ <Skeleton className="h-2.5 w-12 bg-white/15" />
414
+ </div>
415
+ ))}
416
+ </div>
417
+ </div>
418
+
419
+ <div className="grid grid-cols-1 lg:grid-cols-4 lg:gap-px">
420
+ {Array.from({ length: 4 }, (_, index) => (
421
+ <div
422
+ key={index}
423
+ className={cn(
424
+ "flex aspect-[4/3] flex-col justify-end p-4 lg:aspect-auto lg:min-h-[440px] lg:pb-[104px]",
425
+ index > 0 && "hidden lg:flex"
426
+ )}
427
+ >
428
+ <Skeleton className="h-[22px] w-[72px] rounded-full bg-white/15" />
429
+ <Skeleton className="mt-2.5 h-4 w-[60%] bg-white/15" />
430
+ <Skeleton className="mt-2 h-3.5 w-[50%] bg-white/15" />
431
+ <Skeleton className="mt-4 h-5 w-[80px] bg-white/15" />
432
+ </div>
433
+ ))}
434
+ </div>
435
+
436
+ <div className="flex flex-col gap-3 px-4 pb-4 pt-3 lg:absolute lg:inset-x-0 lg:bottom-0 lg:flex-row lg:items-center lg:gap-5 lg:px-5 lg:pb-4 lg:pt-6">
437
+ <Skeleton className="h-3 w-full bg-white/15 lg:flex-1" />
438
+ <Skeleton className="h-10 w-full rounded-full bg-white/15 lg:w-[120px]" />
439
+ </div>
440
+ </div>
441
+ </div>
442
+ );
443
+ }
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ConceptConvergence } from "./concept-convergence";
3
+
4
+ function photo(hue: number) {
5
+ return (
6
+ "data:image/svg+xml;utf8," +
7
+ encodeURIComponent(
8
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="320" height="400" fill="url(#g)"/><circle cx="90" cy="120" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/></svg>`
9
+ )
10
+ );
11
+ }
12
+
13
+ const meta: Meta<typeof ConceptConvergence> = {
14
+ title: "Menu/ConceptConvergence",
15
+ component: ConceptConvergence,
16
+ tags: ["autodocs"],
17
+ decorators: [
18
+ (Story) => (
19
+ <div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ args: {
25
+ aliases: [
26
+ { label: "Papanași", price: "18 lei" },
27
+ { label: "Papanasi cu smantana si dulceata", price: "24 lei" },
28
+ { label: "Papanași de casă", price: "28 lei" },
29
+ { label: "Papanasi (2 buc)", price: "32 lei" },
30
+ { label: "Papanași cu fineți", price: "44 lei" },
31
+ ],
32
+ concept: { title: "Papanași", photoUrl: photo(30), badge: "32 names" },
33
+ scale: ["8 lei", "45 kitchens", "44 lei"],
34
+ },
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Many names, one dish, drawn on the explainer's dark stage. The kitchens' own spellings of a dish, each with its price, converge on the concept they were matched into — the proof the concepts explainer opens with. Chips are white on ink; the concept card is a room's real photograph on the shared `PhotoGround`.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof ConceptConvergence>;
47
+
48
+ export const Default: Story = {};
49
+
50
+ /** No photograph for the lead room: the ground with the initial. */
51
+ export const NoPhoto: Story = {
52
+ args: { concept: { title: "Ciorbă de burtă", badge: "12 names" } },
53
+ };
@@ -0,0 +1,90 @@
1
+ import { cn } from "../lib/utils";
2
+ import { Badge } from "./badge";
3
+ import { Convergence } from "./convergence";
4
+ import { PhotoGround } from "./photo-ground";
5
+
6
+ /**
7
+ * ConceptConvergence — many names, one dish, drawn on a dark stage.
8
+ *
9
+ * The explainer's proof: the kitchens' OWN spellings of a dish, each with its
10
+ * price, converging on the one concept they were matched into. Five visibly
11
+ * different strings under one photograph with one price column say what a
12
+ * paragraph about "matching" cannot.
13
+ *
14
+ * Drawn for the explainer's dark stage, and only there: the chips are white
15
+ * on ink, not a family's hue — the tint was doing no work once the stage
16
+ * went dark, and the spellings are the point, not the kitchens' cuisines.
17
+ * The concept card is the venue's real photograph, never the item's (which
18
+ * is generated), on the same ground as every photographed card.
19
+ */
20
+
21
+ export type ConceptConvergenceAlias = {
22
+ /** The kitchen's spelling, verbatim. */
23
+ label: string;
24
+ /** Pre-formatted, e.g. "32 lei". */
25
+ price: string;
26
+ };
27
+
28
+ export type ConceptConvergenceProps = {
29
+ aliases: ConceptConvergenceAlias[];
30
+ concept: {
31
+ title: string;
32
+ /** A room's photograph — venue photography only. */
33
+ photoUrl?: string;
34
+ /** "32 names" — the badge on the card. */
35
+ badge: string;
36
+ };
37
+ /**
38
+ * Low, middle and high of the ruler under the diagram, e.g.
39
+ * `["8 lei", "45 kitchens", "44 lei"]`. All three, or none.
40
+ */
41
+ scale?: [string, string, string];
42
+ className?: string;
43
+ };
44
+
45
+ export function ConceptConvergence({ aliases, concept, scale, className }: ConceptConvergenceProps) {
46
+ return (
47
+ <div className={cn("flex flex-col gap-3 text-white", className)}>
48
+ <Convergence
49
+ linesClassName="text-white/30"
50
+ sources={aliases.map((alias, index) => (
51
+ <span
52
+ key={index}
53
+ className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-white/10 px-2.5 text-[11.5px] text-white"
54
+ >
55
+ <span className="min-w-0 truncate">{alias.label}</span>
56
+ <span className="shrink-0 tabular-nums text-white/60">{alias.price}</span>
57
+ </span>
58
+ ))}
59
+ target={
60
+ <span className="relative isolate block aspect-[4/5] w-[112px] overflow-hidden rounded-[16px] shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
61
+ <PhotoGround
62
+ className="-z-10"
63
+ mode="stack"
64
+ photos={[concept.photoUrl]}
65
+ initials={concept.title.charAt(0).toLocaleUpperCase()}
66
+ overlay
67
+ loading="eager"
68
+ />
69
+ <Badge variant="overlay" className="absolute left-2 top-2">
70
+ {concept.badge}
71
+ </Badge>
72
+ <span className="absolute inset-x-2 bottom-2 block text-balance font-serif text-[17px] font-semibold leading-tight tracking-[-0.02em] [text-shadow:0_1px_8px_oklch(0_0_0/0.5)]">
73
+ {concept.title}
74
+ </span>
75
+ </span>
76
+ }
77
+ />
78
+
79
+ {scale && (
80
+ <div className="flex items-center gap-2 text-[11px] tabular-nums text-white/60">
81
+ <span>{scale[0]}</span>
82
+ <span aria-hidden className="h-px flex-1 bg-white/20" />
83
+ <span className="font-semibold text-white">{scale[1]}</span>
84
+ <span aria-hidden className="h-px flex-1 bg-white/20" />
85
+ <span>{scale[2]}</span>
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MessageSquareQuote } from "lucide-react";
3
+ import { Convergence } from "./convergence";
4
+
5
+ const meta: Meta<typeof Convergence> = {
6
+ title: "Menu/Convergence",
7
+ component: Convergence,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[360px] bg-background p-4">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "Many things on the left becoming one thing on the right. The DS draws the shape — the fanned stack, the hairlines curving into one point — and the caller draws the nodes. `ConceptConvergence` is the dish version; this story is the reviews version, redacted lines converging on a mention count.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof Convergence>;
28
+
29
+ export const Reviews: Story = {
30
+ render: () => (
31
+ <Convergence
32
+ linesClassName="text-white/30"
33
+ sources={Array.from({ length: 6 }, (_, index) => (
34
+ <span key={index} className="flex items-center gap-2" aria-hidden>
35
+ <MessageSquareQuote className="h-4 w-4 text-white/40" />
36
+ <span className="h-2 rounded-full bg-white/20" style={{ width: 48 + ((index * 37) % 60) }} />
37
+ </span>
38
+ ))}
39
+ target={
40
+ <span className="flex flex-col items-center rounded-[18px] bg-white/10 px-5 py-4 text-white shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
41
+ <span className="text-[40px] font-extrabold leading-none tabular-nums">56</span>
42
+ <span className="mt-1 text-[11px] text-white/70">mentions</span>
43
+ </span>
44
+ }
45
+ />
46
+ ),
47
+ decorators: [
48
+ (Story) => (
49
+ <div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
50
+ <Story />
51
+ </div>
52
+ ),
53
+ ],
54
+ };