@bearmenu/ui 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
  3. package/dist/chunk-2XCZJWTV.js +121 -0
  4. package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
  5. package/dist/chunk-5RWTOT5Y.js +72 -0
  6. package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
  7. package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  8. package/dist/chunk-CLLY6XB3.js +41 -0
  9. package/dist/chunk-DTXNDLSE.js +125 -0
  10. package/dist/chunk-F6FMZJDG.js +113 -0
  11. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  12. package/dist/chunk-H3ZZDX5L.js +61 -0
  13. package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
  14. package/dist/chunk-MZL7CGCL.js +109 -0
  15. package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
  16. package/dist/chunk-WCTHZKU7.js +37 -0
  17. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  18. package/dist/components/alert-dialog.js +1 -1
  19. package/dist/components/alert.d.ts +2 -2
  20. package/dist/components/avatar.d.ts +3 -3
  21. package/dist/components/badge.d.ts +1 -1
  22. package/dist/components/badge.js +1 -1
  23. package/dist/components/button.d.ts +2 -2
  24. package/dist/components/button.js +1 -1
  25. package/dist/components/calendar.js +1 -1
  26. package/dist/components/card.d.ts +4 -4
  27. package/dist/components/carousel.js +1 -1
  28. package/dist/components/combobox.js +1 -1
  29. package/dist/components/command.d.ts +2 -2
  30. package/dist/components/concept-compare-rail.d.ts +50 -0
  31. package/dist/components/concept-compare-rail.js +5 -0
  32. package/dist/components/concept-compare.d.ts +75 -38
  33. package/dist/components/concept-compare.js +209 -133
  34. package/dist/components/concept-convergence.d.ts +41 -0
  35. package/dist/components/concept-convergence.js +53 -0
  36. package/dist/components/convergence.d.ts +25 -0
  37. package/dist/components/convergence.js +4 -0
  38. package/dist/components/dialog.d.ts +2 -2
  39. package/dist/components/empty-state.js +1 -1
  40. package/dist/components/event-card.js +1 -1
  41. package/dist/components/event-compact-row.js +1 -1
  42. package/dist/components/event-detail-panel.js +2 -2
  43. package/dist/components/event-index-row.js +1 -1
  44. package/dist/components/event-list-row-wide.js +2 -2
  45. package/dist/components/event-list-row.js +2 -2
  46. package/dist/components/explainer-steps.d.ts +34 -0
  47. package/dist/components/explainer-steps.js +36 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/glass-ground.d.ts +61 -0
  50. package/dist/components/glass-ground.js +3 -0
  51. package/dist/components/haptic-button.js +1 -1
  52. package/dist/components/hub-surface.js +11 -1
  53. package/dist/components/hue-chip.d.ts +70 -0
  54. package/dist/components/hue-chip.js +3 -0
  55. package/dist/components/input-group.d.ts +2 -2
  56. package/dist/components/input.d.ts +1 -1
  57. package/dist/components/item.d.ts +4 -4
  58. package/dist/components/kbd.d.ts +2 -2
  59. package/dist/components/kitchen-dossier.d.ts +88 -0
  60. package/dist/components/kitchen-dossier.js +186 -0
  61. package/dist/components/link-button.js +1 -1
  62. package/dist/components/menu-dead-end.d.ts +56 -0
  63. package/dist/components/menu-dead-end.js +49 -0
  64. package/dist/components/menu-family-block.d.ts +98 -0
  65. package/dist/components/menu-family-block.js +153 -0
  66. package/dist/components/menu-family-card.d.ts +33 -27
  67. package/dist/components/menu-family-card.js +41 -85
  68. package/dist/components/menu-field.js +92 -2
  69. package/dist/components/menu-filter-chips.d.ts +86 -0
  70. package/dist/components/menu-filter-chips.js +122 -0
  71. package/dist/components/menu-group-rows.d.ts +53 -0
  72. package/dist/components/menu-group-rows.js +67 -0
  73. package/dist/components/menu-hub-hero.js +10 -5
  74. package/dist/components/menu-index-header.d.ts +37 -0
  75. package/dist/components/menu-index-header.js +68 -0
  76. package/dist/components/menu-index-list.d.ts +48 -0
  77. package/dist/components/menu-index-list.js +96 -0
  78. package/dist/components/menu-intent-hero.d.ts +69 -0
  79. package/dist/components/menu-intent-hero.js +87 -0
  80. package/dist/components/menu-item.js +3 -3
  81. package/dist/components/menu-kitchen-list.d.ts +82 -0
  82. package/dist/components/menu-kitchen-list.js +5 -0
  83. package/dist/components/menu-mocks.d.ts +37 -2
  84. package/dist/components/menu-mocks.js +152 -27
  85. package/dist/components/menu-types.d.ts +182 -6
  86. package/dist/components/motion-icon-controls.js +2 -2
  87. package/dist/components/motion-icon-set.js +6 -6
  88. package/dist/components/motion-icon.d.ts +22 -1
  89. package/dist/components/motion-icon.js +1 -1
  90. package/dist/components/multi-select-combobox.js +2 -2
  91. package/dist/components/native-select.d.ts +2 -2
  92. package/dist/components/pagination.js +1 -1
  93. package/dist/components/photo-ground.d.ts +64 -0
  94. package/dist/components/photo-ground.js +3 -0
  95. package/dist/components/place-about-tab.js +2 -2
  96. package/dist/components/place-details-mobile.js +3 -3
  97. package/dist/components/place-types.d.ts +2 -2
  98. package/dist/components/qa-block.d.ts +36 -0
  99. package/dist/components/qa-block.js +24 -0
  100. package/dist/components/scroll-fade.js +3 -121
  101. package/dist/components/searchable-select.js +1 -1
  102. package/dist/components/select.d.ts +1 -1
  103. package/dist/components/signature-chip.d.ts +2 -2
  104. package/dist/components/signature-chip.js +2 -2
  105. package/dist/components/sliding-panels.js +1 -1
  106. package/dist/components/snap-rail.d.ts +26 -3
  107. package/dist/components/snap-rail.js +3 -109
  108. package/dist/components/spinner.d.ts +4 -4
  109. package/dist/components/story.d.ts +34 -0
  110. package/dist/components/story.js +3 -0
  111. package/dist/components/tag.d.ts +4 -4
  112. package/dist/components/text.d.ts +5 -5
  113. package/dist/components/toggle.d.ts +1 -1
  114. package/dist/components/top-comparison.d.ts +28 -0
  115. package/dist/components/top-comparison.js +76 -0
  116. package/dist/components/top-cross-link.d.ts +34 -0
  117. package/dist/components/top-cross-link.js +101 -0
  118. package/dist/components/top-ranking-entry.d.ts +53 -0
  119. package/dist/components/top-ranking-entry.js +217 -0
  120. package/dist/components/top-ranking-hero.d.ts +61 -0
  121. package/dist/components/top-ranking-hero.js +99 -0
  122. package/dist/components/top-ranking-legend.d.ts +69 -0
  123. package/dist/components/top-ranking-legend.js +124 -0
  124. package/dist/components/top-types.d.ts +109 -0
  125. package/dist/components/top-types.js +1 -0
  126. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  127. package/dist/components/venue-grade-dimensions.js +1 -1
  128. package/dist/components/venue-quotes.d.ts +1 -1
  129. package/dist/components/venue-quotes.js +1 -1
  130. package/dist/components/venue-wall.js +5 -3
  131. package/dist/components/weekday-theme-banner.js +1 -1
  132. package/dist/components/weekend-panel.js +2 -2
  133. package/package.json +1 -1
  134. package/src/components/badge.tsx +1 -1
  135. package/src/components/button.tsx +2 -2
  136. package/src/components/concept-compare-rail.tsx +132 -0
  137. package/src/components/concept-compare.stories.tsx +122 -54
  138. package/src/components/concept-compare.tsx +368 -196
  139. package/src/components/concept-convergence.stories.tsx +53 -0
  140. package/src/components/concept-convergence.tsx +90 -0
  141. package/src/components/convergence.stories.tsx +54 -0
  142. package/src/components/convergence.tsx +177 -0
  143. package/src/components/explainer-steps.stories.tsx +46 -0
  144. package/src/components/explainer-steps.tsx +74 -0
  145. package/src/components/glass-ground.tsx +104 -0
  146. package/src/components/hub-surface.tsx +11 -1
  147. package/src/components/hue-chip.stories.tsx +76 -0
  148. package/src/components/hue-chip.test.tsx +62 -0
  149. package/src/components/hue-chip.tsx +99 -0
  150. package/src/components/kitchen-dossier.stories.tsx +108 -0
  151. package/src/components/kitchen-dossier.tsx +321 -0
  152. package/src/components/menu-dead-end.stories.tsx +67 -0
  153. package/src/components/menu-dead-end.tsx +127 -0
  154. package/src/components/menu-family-block.stories.tsx +91 -0
  155. package/src/components/menu-family-block.tsx +303 -0
  156. package/src/components/menu-family-card.stories.tsx +43 -35
  157. package/src/components/menu-family-card.tsx +115 -142
  158. package/src/components/menu-filter-chips.stories.tsx +107 -0
  159. package/src/components/menu-filter-chips.tsx +238 -0
  160. package/src/components/menu-group-rows.stories.tsx +54 -0
  161. package/src/components/menu-group-rows.tsx +133 -0
  162. package/src/components/menu-hub-hero.test.tsx +39 -0
  163. package/src/components/menu-hub-hero.tsx +17 -5
  164. package/src/components/menu-index-header.stories.tsx +72 -0
  165. package/src/components/menu-index-header.tsx +125 -0
  166. package/src/components/menu-index-list.stories.tsx +50 -0
  167. package/src/components/menu-index-list.tsx +149 -0
  168. package/src/components/menu-intent-hero.stories.tsx +67 -0
  169. package/src/components/menu-intent-hero.tsx +189 -0
  170. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  171. package/src/components/menu-kitchen-list.test.tsx +78 -0
  172. package/src/components/menu-kitchen-list.tsx +284 -0
  173. package/src/components/menu-mocks.ts +198 -23
  174. package/src/components/menu-types.ts +189 -6
  175. package/src/components/motion-icon-controls.tsx +11 -11
  176. package/src/components/motion-icon-set.tsx +6 -5
  177. package/src/components/motion-icon.test.tsx +23 -1
  178. package/src/components/motion-icon.tsx +20 -0
  179. package/src/components/photo-ground.tsx +147 -0
  180. package/src/components/place-types.ts +2 -2
  181. package/src/components/qa-block.stories.tsx +43 -0
  182. package/src/components/qa-block.tsx +60 -0
  183. package/src/components/signature-chip.tsx +3 -3
  184. package/src/components/snap-rail.tsx +58 -15
  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.tsx +2 -2
  199. package/src/components/venue-quotes.stories.tsx +1 -1
  200. package/src/components/venue-quotes.tsx +4 -2
  201. package/src/components/venue-wall.tsx +9 -4
  202. package/src/components/weekday-theme-banner.tsx +1 -1
  203. package/dist/chunk-THOVPZNN.js +0 -37
  204. package/dist/chunk-YS6BHKZO.js +0 -92
  205. package/dist/components/ranked-venue-list.d.ts +0 -49
  206. package/dist/components/ranked-venue-list.js +0 -87
  207. package/dist/components/signature-line-list.d.ts +0 -45
  208. package/dist/components/signature-line-list.js +0 -99
  209. package/dist/components/venue-busyness-strip.d.ts +0 -35
  210. package/dist/components/venue-busyness-strip.js +0 -37
  211. package/dist/components/venue-signature-block.d.ts +0 -39
  212. package/dist/components/venue-signature-block.js +0 -6
  213. package/dist/components/venue-spotlight.d.ts +0 -74
  214. package/dist/components/venue-spotlight.js +0 -138
  215. package/src/components/ranked-venue-list.stories.tsx +0 -80
  216. package/src/components/ranked-venue-list.tsx +0 -177
  217. package/src/components/signature-line-list.stories.tsx +0 -85
  218. package/src/components/signature-line-list.tsx +0 -136
  219. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  220. package/src/components/venue-busyness-strip.tsx +0 -64
  221. package/src/components/venue-signature-block.stories.tsx +0 -68
  222. package/src/components/venue-signature-block.tsx +0 -71
  223. package/src/components/venue-spotlight.stories.tsx +0 -141
  224. package/src/components/venue-spotlight.tsx +0 -236
  225. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -1,34 +1,64 @@
1
- import { MessageSquare, Star } from "lucide-react";
1
+ import { ArrowDown, MapPin, Star } from "lucide-react";
2
2
  import { cn } from "../lib/utils";
3
- import { MenuField } from "./menu-field";
4
- import { MenuItem, type MenuItemLabels } from "./menu-item";
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";
5
7
  import type { MenuConceptPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
8
 
7
9
  /**
8
- * ConceptCompare — one dish or drink across every kitchen that makes it, under
9
- * whatever name each of them uses.
10
+ * ConceptCompare — one dish or drink across the kitchens that make it, under
11
+ * whatever name each of them uses. No surfaces at all.
10
12
  *
11
13
  * The keystone module: a single menu can list papanași, only a catalogue can
12
- * say that seventy kitchens make them under fifty different names between 8 and
13
- * 44 lei. The alias column is what makes it honest — these are not the same row
14
- * seventy times, they are seventy differently-named rows we matched, and
15
- * showing each kitchen's own wording is the evidence for that.
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.
16
17
  *
17
- * ## The row is place-led
18
+ * ## The card IS the rooms
18
19
  *
19
- * Each row leads with the VENUE PHOTOGRAPH, which is real and licensed. It
20
- * replaced a price bar, and the trade is not close: the bar measured the one
21
- * value already printed beside it in words, while the thing a reader is
22
- * actually choosing between the restaurant had no image at all. Five
23
- * restaurants that disagree about a name is the story; five bars of similar
24
- * length is not.
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
25
  *
26
- * ## Two layouts, one component
26
+ * ## Read across on desktop, one at a time on the phone
27
27
  *
28
- * From `lg` the row is a table: rank, venue, alias, mentions, price. Below it,
29
- * the rows become the production `<MenuItem>` the alias as the name, the
30
- * venue underneath because a five-column table does not survive 390px and
31
- * the item row already solves this exact shape everywhere else in the app.
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.
32
62
  */
33
63
 
34
64
  /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
@@ -39,52 +69,109 @@ function DefaultLink({ href, ...props }: ConceptCompareLinkProps) {
39
69
  }
40
70
 
41
71
  export type ConceptCompareFigure = {
42
- /** Pre-formatted, e.g. "44". */
72
+ /** Pre-formatted, e.g. "44" or "29–52". */
43
73
  value: string;
44
74
  label: string;
75
+ /** Draws the value in amber — the spread, which is the number the card is about. */
76
+ accent?: boolean;
45
77
  };
46
78
 
47
79
  export type ConceptCompareLabels = {
48
80
  /** Small caps kicker, e.g. "DISH CONCEPT". */
49
81
  kind: string;
50
- /** Column heading over the aliases, e.g. "ON THEIR MENU AS". */
51
- alias: string;
52
- /** Under each price, e.g. "per portion". */
53
- perPortion?: string;
54
- /** Badge on the lowest-priced row, e.g. "cheapest shown". */
55
- cheapest?: 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;
56
90
  };
57
91
 
58
92
  export type ConceptCompareProps = {
59
93
  concept: MenuConceptPreview;
60
94
  labels: ConceptCompareLabels;
61
- /** Passed to the mobile `<MenuItem>` rows. */
62
- itemLabels: MenuItemLabels;
95
+ /** One line under the title, e.g. "45 kitchens make it, and they do not agree on the name". */
96
+ lede?: string;
63
97
  formatPrice: (amount: number) => string;
64
- formatCount?: (value: number) => string;
65
- /** CSS gradient for the header swatch, when there is no photograph. */
66
- gradient: string;
67
- /** A real photograph of the concept, when the matched menu row carries one. */
68
- imageUrl?: string;
69
98
  figures?: ConceptCompareFigure[];
70
- /** Destination per row, keyed by place slug. */
99
+ /** Destination per room, keyed by place slug. A room without one is inert. */
71
100
  hrefs?: Record<string, string>;
72
- /** The provenance note in the footer. */
101
+ /** The provenance note in the foot. */
73
102
  footnote?: string;
74
- /** Actions in the footer — pass rendered links or buttons. */
103
+ /** The action in the foot — pass a rendered link or button. */
75
104
  actions?: React.ReactNode;
76
105
  linkComponent?: MenuLinkComponent;
77
106
  className?: string;
78
107
  };
79
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 ink. It runs the band's full height and goes to ZERO at its
139
+ * underside on an eased curve — a soft edge, not a line. Ink only: the
140
+ * backdrop-blur AND the mask that used to sit under it were removed for the
141
+ * measurement recorded in `GlassGround`. Without the mask the gradient alone
142
+ * shapes the fade, so its lower half is darker than before (~0.28 at 75 %
143
+ * where mask×gradient gave ~0.08) — deliberate, that is where the room
144
+ * names begin.
145
+ */
146
+ const HEAD_GLASS =
147
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.86)_0%,oklch(0.16_0.012_55_/_0.7)_35%,oklch(0.16_0.012_55_/_0.28)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
148
+
149
+ /* The foot band's glass lives in `concept-compare-rail.tsx`, with the foot. */
150
+
151
+ /**
152
+ * Desktop column templates by room count — literal, for the scanner. The phone
153
+ * ignores these: there the rail lays the rooms out.
154
+ */
155
+ const COLUMNS: Record<number, string> = {
156
+ 1: "lg:grid-cols-1",
157
+ 2: "lg:grid-cols-2",
158
+ 3: "lg:grid-cols-3",
159
+ 4: "lg:grid-cols-4",
160
+ };
161
+
162
+ /**
163
+ * How far a room's text sits above the card's foot on desktop, where the foot
164
+ * is an absolute band over the rooms. The band is one line of prose and a
165
+ * button, ~80px; the padding clears it with room to spare at `lg`, where the
166
+ * prose can wrap to two lines.
167
+ */
168
+ const DESKTOP_FOOT_CLEARANCE = "lg:pb-[104px]";
169
+
80
170
  export function ConceptCompare({
81
171
  concept,
82
172
  labels,
83
- itemLabels,
173
+ lede,
84
174
  formatPrice,
85
- formatCount,
86
- gradient,
87
- imageUrl,
88
175
  figures,
89
176
  hrefs,
90
177
  footnote,
@@ -92,185 +179,270 @@ export function ConceptCompare({
92
179
  linkComponent: LinkComponent = DefaultLink,
93
180
  className,
94
181
  }: ConceptCompareProps) {
95
- const rows = concept.rows ?? [];
96
- // Derived, not passed: the cheapest row is a fact about THIS list, and a
182
+ const rooms = (concept.rows ?? []).slice(0, MAX_ROOMS);
183
+ const count = rooms.length;
184
+ if (count === 0) return null;
185
+
186
+ // Derived, not passed: the cheapest room is a fact about THIS list, and a
97
187
  // caller computing it separately could disagree with what is rendered.
98
- const cheapest = rows.reduce<number | undefined>(
99
- (lowest, row) => (lowest === undefined || row.price < lowest ? row.price : lowest),
188
+ const cheapest = rooms.reduce<number | undefined>(
189
+ (lowest, room) => (lowest === undefined || room.price < lowest ? room.price : lowest),
100
190
  undefined
101
191
  );
192
+ const rankOf = labels.rank ?? ((rank: number) => `#${rank}`);
193
+ const hasFoot = Boolean(footnote || actions);
102
194
 
103
195
  return (
104
- <div className={cn("overflow-hidden rounded-[18px] border border-border bg-card", className)}>
105
- <div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:gap-6 lg:p-5">
106
- {imageUrl ? (
107
- // eslint-disable-next-line @next/next/no-img-element
108
- <img
109
- src={imageUrl}
110
- alt=""
111
- className="aspect-[16/9] w-full shrink-0 rounded-xl object-cover lg:aspect-auto lg:h-[88px] lg:w-[88px]"
112
- />
113
- ) : (
114
- <MenuField
115
- name={concept.title}
116
- gradient={gradient}
117
- seedKey={concept.slug}
118
- className="aspect-[16/9] w-full shrink-0 rounded-xl lg:aspect-auto lg:h-[88px] lg:w-[88px]"
119
- />
120
- )}
121
-
122
- <div className="min-w-0 flex-1">
123
- <span className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
124
- {labels.kind}
125
- </span>
126
- <h3 className="mt-1 font-serif text-2xl font-semibold tracking-[-0.02em] lg:text-[30px]">
127
- {concept.title}
128
- </h3>
129
- </div>
130
-
131
- {figures && figures.length > 0 && (
132
- <div className="flex shrink-0 gap-6 lg:gap-8">
133
- {figures.map((figure) => (
134
- <div key={figure.label} className="text-left lg:text-right">
135
- <div className="text-xl font-extrabold leading-none tracking-[-0.03em] tabular-nums lg:text-2xl">
136
- {figure.value}
137
- </div>
138
- <div className="mt-1 text-[10.5px] text-muted-foreground">{figure.label}</div>
139
- </div>
140
- ))}
196
+ <div
197
+ className={cn(
198
+ /* The edge, OUTSIDE the dark scope — the one thing about this card
199
+ that belongs to the page it sits on. */
200
+ "overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
201
+ className
202
+ )}
203
+ >
204
+ <div className={cn("dark relative isolate flex flex-col text-foreground", GROUND)}>
205
+ {/* ── The identity ─────────────────────────────────────────────── */}
206
+ {/* A `div`, not `<header>`. Outside `<article>`/`<section>` a `<header>`
207
+ is the `banner` landmark and a `<footer>` is `contentinfo`, so each
208
+ card would have announced itself as the page's own header and
209
+ footer to a screen reader. */}
210
+ <div
211
+ className={cn(
212
+ "relative isolate flex flex-col gap-3 px-4 pb-5 pt-4",
213
+ "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",
214
+ HEAD_GLASS
215
+ )}
216
+ >
217
+ <div className="min-w-0">
218
+ <span className="block text-[10.5px] font-extrabold uppercase tracking-[0.13em] text-white/70">
219
+ {labels.kind}
220
+ </span>
221
+ <h3 className="mt-1 font-serif text-[28px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
222
+ {concept.title}
223
+ </h3>
224
+ {lede && <p className="mt-1 text-[13px] leading-snug text-white/75">{lede}</p>}
141
225
  </div>
142
- )}
143
- </div>
144
-
145
- {/* ── Below `lg`: the production item row ─────────────────────────── */}
146
- <ul className="border-t border-border lg:hidden">
147
- {rows.map((row) => (
148
- <li key={row.place_slug} className="border-b border-border last:border-b-0">
149
- <MenuItem
150
- item={{
151
- id: row.place_slug,
152
- slug: row.place_slug,
153
- // The kitchen's OWN wording leads the row — that IS the point
154
- // of the module.
155
- name: row.alias,
156
- kind: concept.kind,
157
- mentions: row.mentions,
158
- sizes_and_prices: [{ size: 0, price: row.price }],
159
- }}
160
- labels={itemLabels}
161
- formatPrice={formatPrice}
162
- formatCount={formatCount}
163
- venueName={row.place_name}
164
- href={hrefs?.[row.place_slug]}
165
- linkComponent={LinkComponent}
166
- className={cn(row.price === cheapest && "bg-accent-subtle/40")}
167
- />
168
- </li>
169
- ))}
170
- </ul>
171
226
 
172
- {/* ── From `lg`: the comparison table ─────────────────────────────── */}
173
- <ol className="hidden border-t border-border lg:block">
174
- {rows.map((row, index) => {
175
- const isCheapest = row.price === cheapest;
176
-
177
- const content = (
178
- <>
179
- <span className="w-5 shrink-0 text-center text-xs font-bold tabular-nums text-muted-foreground">
180
- {index + 1}
181
- </span>
227
+ {figures && figures.length > 0 && (
228
+ <dl className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 lg:gap-x-6">
229
+ {figures.map((figure) => (
230
+ <div key={figure.label} className="flex flex-col items-start lg:items-center">
231
+ <dd
232
+ className={cn(
233
+ "text-[22px] font-bold leading-none tabular-nums tracking-[-0.02em]",
234
+ figure.accent ? "text-[oklch(0.82_0.15_85)]" : "text-white"
235
+ )}
236
+ >
237
+ {figure.value}
238
+ </dd>
239
+ <dt className="mt-1 text-[11px] text-white/65">{figure.label}</dt>
240
+ </div>
241
+ ))}
242
+ </dl>
243
+ )}
244
+ </div>
182
245
 
183
- <span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
184
- {row.place_thumbnail_url && (
185
- // eslint-disable-next-line @next/next/no-img-element
186
- <img
187
- src={row.place_thumbnail_url}
188
- alt=""
189
- loading="lazy"
190
- className="h-full w-full object-cover"
191
- />
246
+ {/* ── The rooms: a rail on the phone, a row from `lg` ──────────── */}
247
+ {/* The rail also owns the phone's backdrop — the card's ground is the
248
+ ACTIVE room's photograph, cross-fading on swipe. That is the one
249
+ piece of state in this card, so it is the one client boundary, and
250
+ it takes only what can cross one: URLs, initials, rendered rooms. */}
251
+ <ConceptCompareRail
252
+ backdrops={rooms.map((room) => ({
253
+ src: room.place_thumbnail_url,
254
+ initial: initialOf(room.place_name),
255
+ }))}
256
+ railClassName={cn(
257
+ "lg:grid lg:gap-px lg:overflow-visible lg:[&>*]:w-auto",
258
+ COLUMNS[count] ?? COLUMNS[4]
259
+ )}
260
+ /* The foot's CONTENT; the rail draws the band, because on the phone
261
+ the page dots belong inside its glass and the dots are the rail's
262
+ state. */
263
+ foot={
264
+ hasFoot ? (
265
+ <>
266
+ {footnote && (
267
+ <p className="min-w-0 flex-1 text-[12px] leading-relaxed text-white/70">
268
+ {footnote}
269
+ </p>
192
270
  )}
193
- {row.open_now && (
194
- <i
195
- aria-hidden
196
- className="absolute right-0.5 top-0.5 h-[6px] w-[6px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
197
- />
271
+ {actions && (
272
+ <div className="flex shrink-0 [&>*]:w-full lg:[&>*]:w-auto">{actions}</div>
198
273
  )}
199
- </span>
200
-
201
- <span className="min-w-0 w-[200px] shrink-0">
202
- <span className="block truncate text-sm font-bold tracking-[-0.01em]">
203
- {row.place_name}
274
+ </>
275
+ ) : undefined
276
+ }
277
+ >
278
+ {rooms.map((room, index) => {
279
+ const href = hrefs?.[room.place_slug];
280
+ const isCheapest = room.price === cheapest;
281
+ const roomClass = cn(
282
+ /* Transparent on the phone — the card behind it is the room's
283
+ photograph — with only its scrim and its line. A photograph of
284
+ its own from `lg`, where four stand side by side. */
285
+ "group/room relative isolate flex aspect-[4/3] flex-col justify-end overflow-hidden p-4",
286
+ "lg:aspect-auto lg:min-h-[440px] lg:p-4",
287
+ DESKTOP_FOOT_CLEARANCE
288
+ );
289
+ const body = (
290
+ <>
291
+ {/* Behind the type: the room's own photograph and its overlay
292
+ (desktop only — on the phone the card's ground is the
293
+ photograph), and the scrim (both). */}
294
+ <span aria-hidden className="absolute inset-0 -z-10">
295
+ <PhotoGround
296
+ className="hidden lg:block"
297
+ photos={[room.place_thumbnail_url]}
298
+ initials={initialOf(room.place_name)}
299
+ mosaicClassName="grid-cols-1"
300
+ overlay
301
+ overlayClassName={PHOTO_OVERLAY_HOVER}
302
+ />
303
+ <span className={ROOM_SCRIM} />
204
304
  </span>
205
- <span className="mt-px flex items-center gap-1.5 text-[11px] text-muted-foreground">
206
- {row.rating != null && (
207
- <>
208
- <Star className="h-2.5 w-2.5" aria-hidden />
209
- <span className="tabular-nums">{row.rating}</span>
210
- </>
305
+
306
+ <span
307
+ className={cn(
308
+ "inline-flex h-[22px] w-max items-center gap-1 rounded-full px-2 text-[10px] font-extrabold uppercase tracking-[0.08em]",
309
+ isCheapest
310
+ ? "bg-success text-white"
311
+ : "bg-white/20 text-white/85"
211
312
  )}
212
- {row.place_area && <span className="truncate">{row.place_area}</span>}
313
+ >
314
+ {isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
315
+ {isCheapest ? labels.cheapest : rankOf(index + 1)}
213
316
  </span>
214
- </span>
215
317
 
216
- <span className="min-w-0 flex-1">
217
- <span className="block text-[9.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
218
- {labels.alias}
318
+ {/* Name and rating on one line: the pin leads, the name takes
319
+ what is left, the star sits at the end. */}
320
+ <span className="mt-2.5 flex items-center gap-1.5 text-white">
321
+ <MapPin className="h-3.5 w-3.5 shrink-0 text-white/70" aria-hidden />
322
+ <b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
323
+ {room.place_name}
324
+ </b>
325
+ {room.rating !== undefined && (
326
+ <span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
327
+ <Star className="h-3 w-3 shrink-0" aria-hidden />
328
+ <span className="tabular-nums">{room.rating}</span>
329
+ </span>
330
+ )}
219
331
  </span>
220
- <span className="block truncate font-serif text-[13px] italic">{row.alias}</span>
221
- </span>
332
+ {room.place_area && (
333
+ <span className="mt-0.5 block text-[12px] text-white/70">{room.place_area}</span>
334
+ )}
222
335
 
223
- {row.mentions != null && row.mentions > 0 && (
224
- <span className="inline-flex shrink-0 items-center gap-1 text-[11px] tabular-nums text-muted-foreground">
225
- <MessageSquare className="h-[11px] w-[11px]" aria-hidden />
226
- {row.mentions}
336
+ {/* The kitchen's own wording the evidence that these are
337
+ matched rows rather than one row repeated. */}
338
+ <span className="mt-2 block truncate font-serif text-[14px] text-white/90">
339
+ {room.alias}
227
340
  </span>
228
- )}
229
341
 
230
- <span className="w-[92px] shrink-0 text-right">
231
- <b
232
- className={cn(
233
- "block text-sm font-extrabold tabular-nums",
234
- isCheapest && "text-[var(--free,currentColor)]"
342
+ {/* The DS rule. `via-white/20` because the dark theme's `--border`
343
+ is a 0.32-lightness grey that vanishes on inked photography. */}
344
+ <Separator className="my-2.5 via-white/20" />
345
+
346
+ <span className="flex items-baseline gap-2">
347
+ <b
348
+ className={cn(
349
+ "text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
350
+ isCheapest ? "text-success" : "text-white"
351
+ )}
352
+ >
353
+ {formatPrice(room.price)}
354
+ </b>
355
+ {room.mentions !== undefined && room.mentions > 0 && (
356
+ <span className="text-[11.5px] text-white/65">
357
+ {labels.mentions(room.mentions)}
358
+ </span>
235
359
  )}
236
- >
237
- {formatPrice(row.price)}
238
- </b>
239
- <span className="block text-[10px] text-muted-foreground">
240
- {isCheapest && labels.cheapest ? labels.cheapest : labels.perPortion}
241
360
  </span>
242
- </span>
243
- </>
244
- );
361
+ </>
362
+ );
363
+
364
+ return href ? (
365
+ <LinkComponent
366
+ key={room.place_slug}
367
+ href={href}
368
+ aria-label={labels.openRoom(room.alias, room.place_name)}
369
+ className={cn(
370
+ roomClass,
371
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
372
+ )}
373
+ >
374
+ {body}
375
+ </LinkComponent>
376
+ ) : (
377
+ <div key={room.place_slug} className={roomClass}>
378
+ {body}
379
+ </div>
380
+ );
381
+ })}
382
+ </ConceptCompareRail>
383
+ </div>
384
+ </div>
385
+ );
386
+ }
245
387
 
246
- const rowClasses = "flex items-center gap-4 px-5 py-3";
388
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
247
389
 
248
- return (
249
- <li key={row.place_slug} className="border-b border-border last:border-b-0">
250
- {hrefs?.[row.place_slug] ? (
251
- <LinkComponent
252
- href={hrefs[row.place_slug]}
253
- className={cn(
254
- rowClasses,
255
- "transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
256
- )}
257
- >
258
- {content}
259
- </LinkComponent>
260
- ) : (
261
- <div className={rowClasses}>{content}</div>
390
+ /**
391
+ * The card's own loading state, at the card's footprint.
392
+ *
393
+ * The ground is the card's own dark surface, so the placeholder is already
394
+ * what the photographs will land on and nothing flashes from paper to ink.
395
+ * The phone shape is one room tall with the head above and the foot below;
396
+ * from `lg` it is the four-room row with the head and foot laid over it.
397
+ */
398
+ export function ConceptCompareSkeleton({ className }: { className?: string }) {
399
+ return (
400
+ <div
401
+ className={cn(
402
+ "overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
403
+ className
404
+ )}
405
+ aria-hidden
406
+ >
407
+ <div className={cn("dark relative flex flex-col text-foreground", GROUND)}>
408
+ <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">
409
+ <div>
410
+ <Skeleton className="h-2.5 w-[90px] bg-white/15" />
411
+ <Skeleton className="mt-2 h-8 w-[160px] bg-white/15" />
412
+ <Skeleton className="mt-2 h-3.5 w-[240px] bg-white/15" />
413
+ </div>
414
+ <div className="flex gap-5">
415
+ {Array.from({ length: 4 }, (_, index) => (
416
+ <div key={index} className="flex flex-col items-start gap-1.5 lg:items-center">
417
+ <Skeleton className="h-6 w-8 bg-white/15" />
418
+ <Skeleton className="h-2.5 w-12 bg-white/15" />
419
+ </div>
420
+ ))}
421
+ </div>
422
+ </div>
423
+
424
+ <div className="grid grid-cols-1 lg:grid-cols-4 lg:gap-px">
425
+ {Array.from({ length: 4 }, (_, index) => (
426
+ <div
427
+ key={index}
428
+ className={cn(
429
+ "flex aspect-[4/3] flex-col justify-end p-4 lg:aspect-auto lg:min-h-[440px] lg:pb-[104px]",
430
+ index > 0 && "hidden lg:flex"
262
431
  )}
263
- </li>
264
- );
265
- })}
266
- </ol>
432
+ >
433
+ <Skeleton className="h-[22px] w-[72px] rounded-full bg-white/15" />
434
+ <Skeleton className="mt-2.5 h-4 w-[60%] bg-white/15" />
435
+ <Skeleton className="mt-2 h-3.5 w-[50%] bg-white/15" />
436
+ <Skeleton className="mt-4 h-5 w-[80px] bg-white/15" />
437
+ </div>
438
+ ))}
439
+ </div>
267
440
 
268
- {(footnote || actions) && (
269
- <div className="flex flex-wrap items-center gap-3 p-4">
270
- {footnote && <span className="text-[11.5px] text-muted-foreground">{footnote}</span>}
271
- {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
441
+ <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">
442
+ <Skeleton className="h-3 w-full bg-white/15 lg:flex-1" />
443
+ <Skeleton className="h-10 w-full rounded-full bg-white/15 lg:w-[120px]" />
272
444
  </div>
273
- )}
445
+ </div>
274
446
  </div>
275
447
  );
276
448
  }