@bearmenu/ui 0.8.13 → 0.8.14

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 (285) hide show
  1. package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
  2. package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
  3. package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
  4. package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
  5. package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
  6. package/dist/chunk-DJD3EMPK.js +126 -0
  7. package/dist/chunk-EZG2NELX.js +5 -0
  8. package/dist/{chunk-VAXOQS7W.js → chunk-F76NCVVR.js} +1 -1
  9. package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
  10. package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
  11. package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
  12. package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
  13. package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
  14. package/dist/chunk-I5V446BX.js +27 -0
  15. package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
  16. package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
  17. package/dist/chunk-LQWGZIYA.js +5 -0
  18. package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
  19. package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
  20. package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
  21. package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
  22. package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
  23. package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
  24. package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
  25. package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
  26. package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
  27. package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
  28. package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
  29. package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
  30. package/dist/{chunk-3NMG3KNA.js → chunk-SXQZ7DLH.js} +3 -3
  31. package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
  32. package/dist/{chunk-FKNBZF5T.js → chunk-WYTJR2YU.js} +1 -1
  33. package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
  34. package/dist/chunk-XCB3F6SF.js +16 -0
  35. package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
  36. package/dist/{chunk-P7XTW2CG.js → chunk-ZLPWEHZO.js} +1 -1
  37. package/dist/components/accordion.js +1 -1
  38. package/dist/components/alert-dialog.js +2 -2
  39. package/dist/components/alert.js +2 -2
  40. package/dist/components/aspect-ratio.js +2 -2
  41. package/dist/components/avatar.js +1 -1
  42. package/dist/components/badge.js +2 -2
  43. package/dist/components/breadcrumb.js +1 -1
  44. package/dist/components/button-group.js +1 -1
  45. package/dist/components/button.js +2 -2
  46. package/dist/components/calendar.js +2 -2
  47. package/dist/components/card.js +3 -3
  48. package/dist/components/carousel.js +2 -2
  49. package/dist/components/category-deck.js +2 -2
  50. package/dist/components/checkbox.js +2 -2
  51. package/dist/components/circular-progress.js +1 -1
  52. package/dist/components/collapsible.js +1 -1
  53. package/dist/components/combobox.js +5 -5
  54. package/dist/components/command.js +3 -3
  55. package/dist/components/concept-card.js +2 -2
  56. package/dist/components/concept-compare-rail.js +3 -3
  57. package/dist/components/concept-compare.d.ts +2 -1
  58. package/dist/components/concept-compare.js +5 -5
  59. package/dist/components/concept-convergence.js +4 -4
  60. package/dist/components/convergence.js +3 -3
  61. package/dist/components/coverflow.js +1 -1
  62. package/dist/components/currency-input.js +2 -2
  63. package/dist/components/date-input.js +2 -2
  64. package/dist/components/day-rail-chip.js +2 -2
  65. package/dist/components/description-list.js +1 -1
  66. package/dist/components/dialog.js +2 -2
  67. package/dist/components/doorway-tiles.d.ts +57 -0
  68. package/dist/components/doorway-tiles.js +86 -0
  69. package/dist/components/drawer.js +1 -1
  70. package/dist/components/drift-rail.js +1 -1
  71. package/dist/components/dropdown-menu.js +1 -1
  72. package/dist/components/empty-state.js +3 -3
  73. package/dist/components/event-card.js +4 -4
  74. package/dist/components/event-compact-row.js +4 -4
  75. package/dist/components/event-day-header.js +2 -2
  76. package/dist/components/event-day-separator.js +3 -3
  77. package/dist/components/event-detail-panel.js +4 -4
  78. package/dist/components/event-hub-hero.js +2 -2
  79. package/dist/components/event-index-row.js +4 -4
  80. package/dist/components/event-list-row-wide.js +5 -5
  81. package/dist/components/event-list-row.js +5 -5
  82. package/dist/components/event-poster-fallback.js +1 -1
  83. package/dist/components/event-types.js +1 -1
  84. package/dist/components/events-empty-state.js +1 -1
  85. package/dist/components/explainer-steps.js +1 -1
  86. package/dist/components/facet-group.js +2 -2
  87. package/dist/components/featured-rail-item.js +2 -2
  88. package/dist/components/field.js +2 -2
  89. package/dist/components/filter-category-row.js +3 -3
  90. package/dist/components/form.js +2 -2
  91. package/dist/components/glass-ground.js +2 -2
  92. package/dist/components/grade-atom.d.ts +3 -0
  93. package/dist/components/grade-atom.js +4 -0
  94. package/dist/components/haptic-button.js +2 -2
  95. package/dist/components/hour-band.js +4 -4
  96. package/dist/components/hour-group.js +4 -4
  97. package/dist/components/hub-surface.js +1 -1
  98. package/dist/components/hue-chip.js +1 -1
  99. package/dist/components/icon.js +1 -1
  100. package/dist/components/input-group.js +1 -1
  101. package/dist/components/input-otp.js +1 -1
  102. package/dist/components/input.js +2 -2
  103. package/dist/components/item.js +1 -1
  104. package/dist/components/kbd.js +1 -1
  105. package/dist/components/kitchen-dossier.d.ts +2 -1
  106. package/dist/components/kitchen-dossier.js +3 -3
  107. package/dist/components/label.js +2 -2
  108. package/dist/components/lazy-markdown.js +1 -1
  109. package/dist/components/link-button.js +2 -2
  110. package/dist/components/markdown-renderer.js +1 -1
  111. package/dist/components/menu-dead-end.d.ts +2 -1
  112. package/dist/components/menu-dead-end.js +1 -1
  113. package/dist/components/menu-family-block.d.ts +2 -1
  114. package/dist/components/menu-family-block.js +14 -8
  115. package/dist/components/menu-family-card.d.ts +2 -1
  116. package/dist/components/menu-family-card.js +1 -1
  117. package/dist/components/menu-field.js +1 -1
  118. package/dist/components/menu-filter-chips.d.ts +2 -1
  119. package/dist/components/menu-filter-chips.js +3 -3
  120. package/dist/components/menu-group-rows.d.ts +2 -1
  121. package/dist/components/menu-group-rows.js +2 -2
  122. package/dist/components/menu-hub-hero.js +1 -1
  123. package/dist/components/menu-index-header.d.ts +2 -1
  124. package/dist/components/menu-index-header.js +2 -2
  125. package/dist/components/menu-index-list.d.ts +2 -1
  126. package/dist/components/menu-index-list.js +4 -4
  127. package/dist/components/menu-intent-hero.d.ts +3 -2
  128. package/dist/components/menu-intent-hero.js +1 -1
  129. package/dist/components/menu-item.d.ts +2 -1
  130. package/dist/components/menu-item.js +4 -4
  131. package/dist/components/menu-kitchen-list.d.ts +2 -1
  132. package/dist/components/menu-kitchen-list.js +4 -4
  133. package/dist/components/menu-mocks.d.ts +16 -5
  134. package/dist/components/menu-mocks.js +23 -2
  135. package/dist/components/menu-types.d.ts +21 -34
  136. package/dist/components/motion-icon-actions.d.ts +27 -0
  137. package/dist/components/motion-icon-actions.js +336 -0
  138. package/dist/components/motion-icon-controls.js +3 -3
  139. package/dist/components/motion-icon-set.d.ts +13 -1
  140. package/dist/components/motion-icon-set.js +197 -6
  141. package/dist/components/motion-icon.d.ts +15 -1
  142. package/dist/components/motion-icon.js +2 -2
  143. package/dist/components/multi-select-combobox.js +7 -7
  144. package/dist/components/multi-select-menu.js +2 -2
  145. package/dist/components/native-select.js +1 -1
  146. package/dist/components/open-now-list.d.ts +2 -1
  147. package/dist/components/open-now-list.js +2 -2
  148. package/dist/components/pagination.js +2 -2
  149. package/dist/components/phone-frame.js +1 -1
  150. package/dist/components/photo-ground.js +1 -1
  151. package/dist/components/place-about-tab.d.ts +2 -1
  152. package/dist/components/place-about-tab.js +6 -6
  153. package/dist/components/place-card.d.ts +94 -12
  154. package/dist/components/place-card.js +203 -128
  155. package/dist/components/place-details-mobile.d.ts +2 -1
  156. package/dist/components/place-details-mobile.js +7 -7
  157. package/dist/components/place-schedule-week.d.ts +2 -1
  158. package/dist/components/place-schedule-week.js +3 -3
  159. package/dist/components/place-types.d.ts +3 -265
  160. package/dist/components/popover.js +2 -2
  161. package/dist/components/poster-stack.js +1 -1
  162. package/dist/components/poster-tape.js +1 -1
  163. package/dist/components/poster-wall.d.ts +22 -6
  164. package/dist/components/poster-wall.js +197 -12
  165. package/dist/components/progress.js +1 -1
  166. package/dist/components/qa-block.d.ts +2 -1
  167. package/dist/components/qa-block.js +1 -1
  168. package/dist/components/quiet-stretch.js +1 -1
  169. package/dist/components/radio-group.js +1 -1
  170. package/dist/components/rating.js +1 -1
  171. package/dist/components/scroll-area.js +1 -1
  172. package/dist/components/scroll-fade.js +2 -2
  173. package/dist/components/search-bar.js +1 -1
  174. package/dist/components/searchable-select.js +5 -5
  175. package/dist/components/section-header.js +2 -2
  176. package/dist/components/segmented-nav.d.ts +24 -4
  177. package/dist/components/segmented-nav.js +11 -2
  178. package/dist/components/select.js +1 -1
  179. package/dist/components/selection-bar.js +1 -1
  180. package/dist/components/separator.js +2 -2
  181. package/dist/components/sheet.js +1 -1
  182. package/dist/components/signature-chip.js +3 -3
  183. package/dist/components/skeleton-card.js +3 -3
  184. package/dist/components/skeleton.js +2 -2
  185. package/dist/components/slider.js +1 -1
  186. package/dist/components/sliding-panels.js +2 -2
  187. package/dist/components/snap-rail.js +2 -2
  188. package/dist/components/sonner.js +1 -1
  189. package/dist/components/sort-option-list.js +1 -1
  190. package/dist/components/spinner.js +1 -1
  191. package/dist/components/stat-banner.js +1 -1
  192. package/dist/components/stepper.js +1 -1
  193. package/dist/components/sticky-container.js +1 -1
  194. package/dist/components/story.js +1 -1
  195. package/dist/components/switch.js +1 -1
  196. package/dist/components/table.js +1 -1
  197. package/dist/components/tabs.js +2 -2
  198. package/dist/components/tag-group.js +2 -2
  199. package/dist/components/tag.js +2 -2
  200. package/dist/components/text.js +2 -2
  201. package/dist/components/textarea.js +1 -1
  202. package/dist/components/timeline-band.js +4 -4
  203. package/dist/components/timeline-playhead.js +4 -4
  204. package/dist/components/toggle-group.js +2 -2
  205. package/dist/components/toggle.js +2 -2
  206. package/dist/components/tooltip.js +1 -1
  207. package/dist/components/top-comparison.d.ts +2 -1
  208. package/dist/components/top-comparison.js +1 -1
  209. package/dist/components/top-cross-link.d.ts +2 -1
  210. package/dist/components/top-cross-link.js +1 -1
  211. package/dist/components/top-ranking-entry.d.ts +2 -1
  212. package/dist/components/top-ranking-entry.js +6 -6
  213. package/dist/components/top-ranking-hero.d.ts +2 -1
  214. package/dist/components/top-ranking-hero.js +1 -1
  215. package/dist/components/top-ranking-legend.js +1 -1
  216. package/dist/components/top-types.d.ts +3 -1
  217. package/dist/components/use-mobile.js +1 -1
  218. package/dist/components/venue-events-block.js +5 -5
  219. package/dist/components/venue-grade-dimensions.d.ts +2 -1
  220. package/dist/components/venue-grade-dimensions.js +1 -1
  221. package/dist/components/venue-quotes.d.ts +2 -1
  222. package/dist/components/venue-quotes.js +1 -1
  223. package/dist/components/venue-wall.d.ts +2 -1
  224. package/dist/components/venue-wall.js +1 -1
  225. package/dist/components/weekday-theme-banner.js +1 -1
  226. package/dist/components/weekend-panel.js +4 -4
  227. package/dist/hooks/use-drag-swipe.js +1 -1
  228. package/dist/hooks/use-haptic.js +1 -1
  229. package/dist/hooks/use-media-query.js +1 -1
  230. package/dist/hooks/use-mobile.js +1 -1
  231. package/dist/hooks/use-mounted-theme.js +1 -1
  232. package/dist/hooks/use-sticky.js +1 -1
  233. package/dist/lib/card-hover.js +2 -18
  234. package/dist/lib/grade.d.ts +54 -0
  235. package/dist/lib/grade.js +2 -0
  236. package/dist/lib/icon-depth.d.ts +35 -0
  237. package/dist/lib/icon-depth.js +2 -0
  238. package/dist/lib/icon-registry.js +1 -1
  239. package/dist/lib/motion.js +2 -2
  240. package/dist/lib/overlay-pill.d.ts +30 -0
  241. package/dist/lib/overlay-pill.js +2 -0
  242. package/dist/lib/utils.js +1 -1
  243. package/dist/place-types-BHdeMMiG.d.ts +317 -0
  244. package/package.json +1 -1
  245. package/src/components/colour-dictionary.stories.tsx +268 -82
  246. package/src/components/doorway-tiles.stories.tsx +83 -0
  247. package/src/components/doorway-tiles.test.tsx +51 -0
  248. package/src/components/doorway-tiles.tsx +225 -0
  249. package/src/components/grade-atom.stories.tsx +81 -0
  250. package/src/components/grade-atom.tsx +256 -0
  251. package/src/components/hue-chip.stories.tsx +2 -2
  252. package/src/components/menu-family-block.stories.tsx +29 -0
  253. package/src/components/menu-family-block.test.tsx +51 -0
  254. package/src/components/menu-family-block.tsx +20 -4
  255. package/src/components/menu-intent-hero.tsx +1 -1
  256. package/src/components/menu-mocks.ts +39 -3
  257. package/src/components/menu-types.ts +17 -33
  258. package/src/components/motion-icon-actions.test.tsx +115 -0
  259. package/src/components/motion-icon-actions.tsx +580 -0
  260. package/src/components/motion-icon-controls.test.tsx +1 -0
  261. package/src/components/motion-icon-controls.tsx +18 -13
  262. package/src/components/motion-icon-set.stories.tsx +62 -3
  263. package/src/components/motion-icon-set.tsx +349 -2
  264. package/src/components/motion-icon.test.tsx +180 -1
  265. package/src/components/motion-icon.tsx +97 -8
  266. package/src/components/place-card.stories.tsx +66 -76
  267. package/src/components/place-card.test.tsx +188 -100
  268. package/src/components/place-card.tsx +356 -148
  269. package/src/components/place-types.ts +17 -4
  270. package/src/components/poster-wall.stories.tsx +84 -0
  271. package/src/components/poster-wall.test.tsx +94 -3
  272. package/src/components/poster-wall.tsx +257 -9
  273. package/src/components/segmented-nav.stories.tsx +72 -1
  274. package/src/components/segmented-nav.test.tsx +96 -0
  275. package/src/components/segmented-nav.tsx +36 -4
  276. package/src/globals.css +26 -0
  277. package/src/lib/grade.test.ts +82 -0
  278. package/src/lib/grade.ts +76 -0
  279. package/src/lib/icon-depth.ts +35 -0
  280. package/src/lib/overlay-pill.ts +29 -0
  281. package/dist/components/menu-family-tiles.d.ts +0 -19
  282. package/dist/components/menu-family-tiles.js +0 -57
  283. package/src/components/menu-family-tiles.stories.tsx +0 -54
  284. package/src/components/menu-family-tiles.test.tsx +0 -30
  285. package/src/components/menu-family-tiles.tsx +0 -132
@@ -29,13 +29,28 @@ import { cn } from "../lib/utils";
29
29
  * which wins via `cn`'s merge.
30
30
  */
31
31
 
32
+ /**
33
+ * What a segment's icon render function receives. `replayToken` bumps on
34
+ * POINTER DOWN on that segment — before any navigation exists — which is what
35
+ * a character-animated glyph (`MotionIcon`) needs to answer the finger rather
36
+ * than the route change that follows it.
37
+ */
38
+ export type SegmentedNavIconState = {
39
+ active: boolean;
40
+ replayToken: number;
41
+ };
42
+
32
43
  export type SegmentedNavItem = {
33
44
  /** Stable identity, compared against `activeId`. */
34
45
  id: string;
35
46
  href: string;
36
47
  label: string;
37
- /** Rendered before the label. Size it at the call site (the design uses 15–16px). */
38
- icon?: React.ReactNode;
48
+ /**
49
+ * Rendered before the label. Size it at the call site (the design uses
50
+ * 15–16px). A function receives the segment's state, for glyphs that
51
+ * perform on touch: `({ active, replayToken }) => <MotionWine active={active} replayToken={replayToken} />`.
52
+ */
53
+ icon?: React.ReactNode | ((state: SegmentedNavIconState) => React.ReactNode);
39
54
  };
40
55
 
41
56
  /**
@@ -48,6 +63,7 @@ export type SegmentedNavLinkProps = {
48
63
  children: React.ReactNode;
49
64
  "aria-current"?: "page";
50
65
  onClick?: () => void;
66
+ onPointerDown?: () => void;
51
67
  };
52
68
 
53
69
  function DefaultLink({ href, ...props }: SegmentedNavLinkProps) {
@@ -60,7 +76,12 @@ export type SegmentedNavProps = {
60
76
  activeId: string;
61
77
  /** Accessible name for the nav landmark. Translated by the caller. */
62
78
  label: string;
63
- /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
79
+ /**
80
+ * Defaults to a plain `<a>`. Pass `next/link` in a Next app. Must be a
81
+ * stable reference (module scope, not an inline arrow): a new component
82
+ * identity per render remounts every segment, and with a function `icon`
83
+ * that cuts the pointer-down gesture off at the click.
84
+ */
64
85
  linkComponent?: React.ComponentType<SegmentedNavLinkProps>;
65
86
  /** Fired with the segment id on click — analytics, haptics. Never navigation. */
66
87
  onSelect?: (id: string) => void;
@@ -75,6 +96,12 @@ export function SegmentedNav({
75
96
  onSelect,
76
97
  className,
77
98
  }: SegmentedNavProps) {
99
+ // One counter per segment, bumped on pointer down. Only a function `icon`
100
+ // ever reads it; a static icon costs nothing but the state slot.
101
+ const [replayTokens, setReplayTokens] = React.useState<Record<string, number>>({});
102
+ const replay = (id: string) =>
103
+ setReplayTokens((tokens) => ({ ...tokens, [id]: (tokens[id] ?? 0) + 1 }));
104
+
78
105
  return (
79
106
  <nav
80
107
  aria-label={label}
@@ -86,12 +113,17 @@ export function SegmentedNav({
86
113
  >
87
114
  {items.map((item) => {
88
115
  const isActive = item.id === activeId;
116
+ const icon =
117
+ typeof item.icon === "function"
118
+ ? item.icon({ active: isActive, replayToken: replayTokens[item.id] ?? 0 })
119
+ : item.icon;
89
120
  return (
90
121
  <LinkComponent
91
122
  key={item.id}
92
123
  href={item.href}
93
124
  aria-current={isActive ? "page" : undefined}
94
125
  onClick={onSelect ? () => onSelect(item.id) : undefined}
126
+ onPointerDown={typeof item.icon === "function" ? () => replay(item.id) : undefined}
95
127
  className={cn(
96
128
  "inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-md px-3",
97
129
  "text-[13px] font-semibold lg:flex-none lg:text-sm",
@@ -103,7 +135,7 @@ export function SegmentedNav({
103
135
  : "text-muted-foreground hover:text-foreground"
104
136
  )}
105
137
  >
106
- {item.icon}
138
+ {icon}
107
139
  {item.label}
108
140
  </LinkComponent>
109
141
  );
package/src/globals.css CHANGED
@@ -234,6 +234,17 @@ h6 {
234
234
  --z-dropdown: 1200;
235
235
  --z-tooltip: 1300;
236
236
  --z-toast: 1400;
237
+
238
+ /* Drop-shadow filter for a glyph on the neutral placeholder panel the card
239
+ photo fallbacks share (`from-muted to-border`). Consumed via
240
+ `NEUTRAL_ICON_FILTER` in `lib/icon-depth.ts`. A black shadow of any spread
241
+ on a near-white panel pools under the glyph and reads as grime; the fix is
242
+ hue and spread, not weakness: both stops are a deep neutral dropped to
243
+ 0.38 L so it still has weight, and the lift caps at 4px. Alpha stays high
244
+ enough to read at a 40px glyph — a warm shadow carries far more opacity
245
+ than a black one before it looks dirty. */
246
+ --icon-depth-neutral: drop-shadow(0 1px 1px oklch(0.38 0 0 / 0.29))
247
+ drop-shadow(0 2px 4px oklch(0.38 0 0 / 0.22));
237
248
  }
238
249
 
239
250
  .dark {
@@ -313,6 +324,21 @@ h6 {
313
324
  The inset well is unchanged between themes, so it has no override. */
314
325
  --shadow-card-image: 0 4px 14px rgba(0, 0, 0, 0.45);
315
326
  --shadow-card-image-hover: 0 12px 28px rgba(0, 0, 0, 0.55);
327
+
328
+ /* Dark placeholder panel is oklch(0.22→0.32) with a light glyph — a wide
329
+ halo does nothing against a dark surface, so it's contact stops only,
330
+ pushed harder since the panel absorbs most of the shadow. */
331
+ --icon-depth-neutral: drop-shadow(0 1px 1px oklch(0 0 0 / 0.47))
332
+ drop-shadow(0 2px 4px oklch(0 0 0 / 0.34));
333
+ }
334
+
335
+ /* Named 11px step (`text-2xs`) for micro-labels: overlay pills, hour
336
+ * sub-headers, hype badge, concept chip. One token instead of scattered
337
+ * `text-[11px]` arbitrary values — the smallest size we allow; anything
338
+ * below is illegible. */
339
+ @theme {
340
+ --text-2xs: 0.6875rem;
341
+ --text-2xs--line-height: 1rem;
316
342
  }
317
343
 
318
344
  @theme inline {
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveGradeBand,
4
+ formatGrade,
5
+ shouldShowGradeNumber,
6
+ resolveDimensionTone,
7
+ } from "./grade";
8
+
9
+ describe("resolveGradeBand", () => {
10
+ it("returns 'exceptional' from 9.0 up to 10", () => {
11
+ expect(resolveGradeBand(9.0)).toBe("exceptional");
12
+ expect(resolveGradeBand(10)).toBe("exceptional");
13
+ });
14
+
15
+ it("returns 'great' across 7.5–8.9", () => {
16
+ expect(resolveGradeBand(7.5)).toBe("great");
17
+ expect(resolveGradeBand(8.9)).toBe("great");
18
+ });
19
+
20
+ it("returns 'good' across 6.0–7.4", () => {
21
+ expect(resolveGradeBand(6.0)).toBe("good");
22
+ expect(resolveGradeBand(7.4)).toBe("good");
23
+ });
24
+
25
+ it("returns 'decent' across 5.0–5.9", () => {
26
+ expect(resolveGradeBand(5.0)).toBe("decent");
27
+ expect(resolveGradeBand(5.9)).toBe("decent");
28
+ });
29
+
30
+ it("returns 'weak' below 5.0", () => {
31
+ expect(resolveGradeBand(4.9)).toBe("weak");
32
+ expect(resolveGradeBand(1)).toBe("weak");
33
+ });
34
+ });
35
+
36
+ describe("formatGrade", () => {
37
+ it("keeps one decimal of precision", () => {
38
+ expect(formatGrade(8.6)).toBe("8.6");
39
+ expect(formatGrade(7.45)).toBe("7.5");
40
+ });
41
+
42
+ it("drops a trailing .0 so whole grades read cleanly", () => {
43
+ expect(formatGrade(7)).toBe("7");
44
+ expect(formatGrade(10)).toBe("10");
45
+ });
46
+
47
+ it("clamps to the 1–10 Romanian school scale", () => {
48
+ expect(formatGrade(11.2)).toBe("10");
49
+ expect(formatGrade(0.4)).toBe("1");
50
+ });
51
+ });
52
+
53
+ describe("resolveDimensionTone", () => {
54
+ it("is 'strong' at 75 and above", () => {
55
+ expect(resolveDimensionTone(75)).toBe("strong");
56
+ expect(resolveDimensionTone(98)).toBe("strong");
57
+ });
58
+
59
+ it("is 'adequate' across 50–74", () => {
60
+ expect(resolveDimensionTone(50)).toBe("adequate");
61
+ expect(resolveDimensionTone(74)).toBe("adequate");
62
+ });
63
+
64
+ it("is 'low' below 50", () => {
65
+ expect(resolveDimensionTone(49)).toBe("low");
66
+ expect(resolveDimensionTone(20)).toBe("low");
67
+ });
68
+ });
69
+
70
+ describe("shouldShowGradeNumber", () => {
71
+ it("hides the number when the backend recommends against it", () => {
72
+ expect(shouldShowGradeNumber({ show_number: false })).toBe(false);
73
+ });
74
+
75
+ it("shows the number when recommended", () => {
76
+ expect(shouldShowGradeNumber({ show_number: true })).toBe(true);
77
+ });
78
+
79
+ it("defaults to showing the number when unspecified", () => {
80
+ expect(shouldShowGradeNumber({})).toBe(true);
81
+ });
82
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure helpers for the ReviewsDigest `grade` (1–10 Romanian school scale,
3
+ * food-anchored). Shared by `<GradeAtom>` and any surface that demotes the
4
+ * Google star in favour of the grade.
5
+ */
6
+
7
+ /** Colour/quality band, derived from `grade.overall`. The label text comes
8
+ * from the caller's `GradeAtomLabels`; the band only drives the visual tone. */
9
+ export type GradeBand = "exceptional" | "great" | "good" | "decent" | "weak";
10
+
11
+ /** Map a 1–10 overall score to its band. Boundaries are inclusive at the low end
12
+ * (9.0 → exceptional, 7.5 → great, 6.0 → good, 5.0 → decent, below 5 → weak). */
13
+ export function resolveGradeBand(overall: number): GradeBand {
14
+ if (overall >= 9) return "exceptional";
15
+ if (overall >= 7.5) return "great";
16
+ if (overall >= 6) return "good";
17
+ if (overall >= 5) return "decent";
18
+ return "weak";
19
+ }
20
+
21
+ /** Format a grade for display: clamp to the 1–10 scale, keep one decimal of
22
+ * precision, and drop a trailing `.0` so whole grades read as "7" not "7.0". */
23
+ export function formatGrade(overall: number): string {
24
+ const clamped = Math.min(10, Math.max(1, overall));
25
+ const rounded = Math.round(clamped * 10) / 10;
26
+ return rounded.toFixed(1).replace(/\.0$/, "");
27
+ }
28
+
29
+ /** The backend `show_number` is a display recommendation: when explicitly
30
+ * false, fall back to the soft state instead of the number. Unspecified → show. */
31
+ export function shouldShowGradeNumber(grade: { show_number?: boolean }): boolean {
32
+ return grade.show_number !== false;
33
+ }
34
+
35
+ /**
36
+ * Whether `<GradeAtom>` will render anything for this grade at this size — the
37
+ * predicate every caller must gate on before choosing the grade over its own
38
+ * Google-star fallback.
39
+ *
40
+ * `overall == null` has always meant "no grade, show the star". The `xs` rule
41
+ * is newer: at card size the right-hand slot is a 2–3 glyph METRIC slot, and
42
+ * the soft state is a two-word sentence ("Worth exploring" / "Merită
43
+ * explorat"). Rendered there it wrapped inside its own fixed-height pill and
44
+ * pushed the title into a hard truncation. The sentence still renders at `sm`+
45
+ * (drawer, hero) where there is room for it.
46
+ *
47
+ * Callers used to gate on `grade?.overall != null` directly, which is why this
48
+ * exists as a shared function: with the size rule added, that check no longer
49
+ * predicts whether the atom renders, and a caller using the old one lands on
50
+ * an empty slot instead of falling through to its star.
51
+ *
52
+ * Lives here rather than in `grade-atom.tsx` so SERVER components can call it —
53
+ * everything exported from a `"use client"` module is a client reference.
54
+ *
55
+ * A type predicate, so the true branch also narrows away the `undefined` that
56
+ * `place.reviews_digest?.grade` carries and the caller can pass it straight to
57
+ * `<GradeAtom grade={...}>`.
58
+ */
59
+ export function showsGradeAtom<T extends { overall?: number | null; show_number?: boolean }>(
60
+ grade: T | null | undefined,
61
+ size: "xs" | "sm" | "md" | "lg"
62
+ ): grade is T {
63
+ if (grade?.overall == null) return false;
64
+ return size !== "xs" || shouldShowGradeNumber(grade);
65
+ }
66
+
67
+ /** Tone for a single 0–100 dimension score, driving the page dimension-bar
68
+ * colour: strong (≥75) = accent, adequate (50–74) = muted, low (<50) = amber
69
+ * (a soft "fair warning", never error-red). */
70
+ export type DimensionTone = "strong" | "adequate" | "low";
71
+
72
+ export function resolveDimensionTone(score: number): DimensionTone {
73
+ if (score >= 75) return "strong";
74
+ if (score >= 50) return "adequate";
75
+ return "low";
76
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Layered drop-shadow filter that "raises" a flat icon off a tinted surface.
3
+ *
4
+ * Two stops stacked: a tight 1px close shadow welds the icon to the surface
5
+ * (so it doesn't float disconnected), then a wider 3px/8px lift gives it
6
+ * presence. The combo is what makes the homepage cuisine tiles read as
7
+ * embossed plaques instead of stickers — reuse it anywhere an icon sits on
8
+ * a warm gradient (entity-card photo fallbacks, etc).
9
+ *
10
+ * Designed for white / light icons on the warm `--gradient-primary` family.
11
+ * Don't apply to icons on light surfaces — the dark drop-shadow stops will
12
+ * read as muddy outlines instead of depth.
13
+ */
14
+ export const RAISED_ICON_FILTER =
15
+ "drop-shadow(0 1px 1px oklch(0 0 0 / 0.6)) drop-shadow(0 3px 8px oklch(0 0 0 / 0.4))";
16
+
17
+ /**
18
+ * The light-surface counterpart, for glyphs on the neutral placeholder panel
19
+ * (`from-muted to-border`) that entity-card photo fallbacks share.
20
+ *
21
+ * `RAISED_ICON_FILTER` is black and wide — correct under a white icon on the
22
+ * brown gradient, wrong everywhere else. On a near-white panel its 8px black
23
+ * stop pools under the glyph and reads as grime rather than lift.
24
+ *
25
+ * What fixes that is hue and spread, not just less shadow: this one is a deep
26
+ * brown (same 45° hue as `--shadow-card`) capped at a 4px lift. Halving the
27
+ * spread is what kills the smudge, which then buys enough headroom on alpha
28
+ * for the glyph to still read as raised rather than washed out — a warm
29
+ * shadow tolerates far more opacity than a black one before it looks dirty.
30
+ *
31
+ * Resolves through a token (`--icon-depth-neutral` in `globals.css`), so dark
32
+ * mode gets its own value — there the panel is already dark and a halo does
33
+ * nothing, so it collapses to one contact stop.
34
+ */
35
+ export const NEUTRAL_ICON_FILTER = "var(--icon-depth-neutral)";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ONE geometry for every pill that sits on a card image — places and events.
3
+ *
4
+ * These had drifted into four different sizes on the event card (hype at
5
+ * `min-h-8`, Free at `text-xs px-2.5 py-1`, concept and tags at `text-[10px]
6
+ * px-2 py-0.5`) and three more on the place card (Trending at `min-h-8`, the
7
+ * Closed pill at `text-xs py-0.5`, the open dot at `h-2`), so corners of the
8
+ * same card — and cards sitting in the same rail — read as unrelated systems.
9
+ *
10
+ * An explicit `h-6` rather than a padding-derived height is what actually
11
+ * guarantees they match: the hype pill contains a gauge glyph, Trending
12
+ * contains a flame, and the rest contain 12px icons, so equal padding would
13
+ * still produce unequal heights.
14
+ *
15
+ * 24px is deliberate over the 32px `heatPillVariants` `md`. The only argument
16
+ * for 32 was the tap target on the interactive Trending pill, and that pill
17
+ * already carries an `after:-inset-2` hit-area expander (~40px effective)
18
+ * which solves it independently of the visual size. 24px is also exactly the
19
+ * WCAG 2.5.8 (AA) minimum on its own, before the expander.
20
+ *
21
+ * Pair with `Badge` (`variant="overlay"` is the scrim: `bg-foreground/80
22
+ * text-background`) rather than hand-rolled spans, which is what let these
23
+ * drift in the first place. Only the radius is overridden — the `Badge`
24
+ * default is `rounded-lg` and pills on photography read better fully round.
25
+ */
26
+ export const OVERLAY_PILL = "h-6 gap-1 rounded-full px-2 text-2xs font-semibold shadow-sm";
27
+
28
+ /** Icon size inside an `OVERLAY_PILL`. */
29
+ export const OVERLAY_ICON = "h-3 w-3 shrink-0";
@@ -1,19 +0,0 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { MenuFamilyTile, MenuLinkComponent } from './menu-types.js';
3
- import 'react';
4
- import './place-types.js';
5
-
6
- type MenuFamilyTilesProps = {
7
- tiles: MenuFamilyTile[];
8
- /**
9
- * The closing tile: the index behind the seven, drawn exactly like them —
10
- * its own hue and photograph, `countLabel` as the caption. Only its place in
11
- * the grid says it is the index. Omit when there is none.
12
- */
13
- all?: MenuFamilyTile;
14
- linkComponent?: MenuLinkComponent;
15
- className?: string;
16
- };
17
- declare function MenuFamilyTiles({ tiles, all, linkComponent: LinkComponent, className, }: MenuFamilyTilesProps): react_jsx_runtime.JSX.Element;
18
-
19
- export { MenuFamilyTiles, type MenuFamilyTilesProps };
@@ -1,57 +0,0 @@
1
- import { PhotoGround } from '../chunk-CNJQUHCU.js';
2
- import { cn } from '../chunk-FEK5XGZW.js';
3
- import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
4
- import { jsx, jsxs } from 'react/jsx-runtime';
5
-
6
- function DefaultLink(_a) {
7
- var _b = _a, { href } = _b, props = __objRest(_b, ["href"]);
8
- return /* @__PURE__ */ jsx("a", __spreadValues({ href }, props));
9
- }
10
- var TILE = "group relative isolate flex aspect-[16/10] min-h-[108px] flex-col justify-end overflow-hidden rounded-2xl bg-muted p-3.5 text-white lg:p-4 transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card-hover)] active:translate-y-0 active:scale-[0.99] motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
11
- var POLAROID = "absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] shadow-[0_10px_22px_-6px_oklch(0.16_0.012_55_/_0.65)] transition-transform duration-300 group-hover:rotate-[5deg] motion-reduce:transition-none";
12
- var SCRIM = "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/5 after:bg-gradient-to-t after:from-[oklch(0.16_0.012_55_/_0.55)] after:to-transparent after:content-['']";
13
- function MenuFamilyTiles({
14
- tiles,
15
- all,
16
- linkComponent: LinkComponent = DefaultLink,
17
- className
18
- }) {
19
- const cells = all ? [...tiles, all] : tiles;
20
- return /* @__PURE__ */ jsx("div", { className: cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className), children: cells.map((tile) => /* @__PURE__ */ jsxs(
21
- LinkComponent,
22
- {
23
- href: tile.href,
24
- className: cn(TILE, SCRIM),
25
- style: tile.gradient ? { backgroundImage: tile.gradient } : void 0,
26
- children: [
27
- /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: POLAROID, children: tile.photoUrl ? (
28
- /* Not lazy: the grid sits directly under the hero, and these
29
- photographs are the first thing the reader looks at. */
30
- // eslint-disable-next-line @next/next/no-img-element
31
- /* @__PURE__ */ jsx(
32
- "img",
33
- {
34
- src: tile.photoUrl,
35
- alt: "",
36
- decoding: "async",
37
- className: "absolute inset-0 h-full w-full object-cover"
38
- }
39
- )
40
- ) : /* @__PURE__ */ jsx(
41
- PhotoGround,
42
- {
43
- photos: [void 0],
44
- initials: tile.label.charAt(0),
45
- mode: "stack",
46
- glyphClassName: "text-[44px]"
47
- }
48
- ) }),
49
- /* @__PURE__ */ jsx("span", { className: "relative z-[1] line-clamp-2 max-w-[55%] text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]", children: tile.label }),
50
- tile.countLabel && /* @__PURE__ */ jsx("span", { className: "relative z-[1] mt-0.5 max-w-[55%] truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]", children: tile.countLabel })
51
- ]
52
- },
53
- tile.slug
54
- )) });
55
- }
56
-
57
- export { MenuFamilyTiles };
@@ -1,54 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react";
2
- import { MenuFamilyTiles } from "./menu-family-tiles";
3
- import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
4
-
5
- const meta: Meta<typeof MenuFamilyTiles> = {
6
- title: "Menu/MenuFamilyTiles",
7
- component: MenuFamilyTiles,
8
- tags: ["autodocs"],
9
- decorators: [
10
- (Story) => (
11
- <div className="w-[1100px] bg-background p-4">
12
- <Story />
13
- </div>
14
- ),
15
- ],
16
- parameters: {
17
- docs: {
18
- description: {
19
- component:
20
- "The hub's doorways: every family the city browses by, as a grid of coloured tiles — the family's name, its venue count and one venue's photograph tilted off the corner — closed by one tile that opens the whole index. Two to a row on a phone, four from `lg`, so seven families and the index make two rows at every width.",
21
- },
22
- },
23
- },
24
- };
25
-
26
- export default meta;
27
- type Story = StoryObj<typeof MenuFamilyTiles>;
28
-
29
- export const Default: Story = { args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll } };
30
-
31
- /** Two-up on a phone — seven families and the index in four rows. */
32
- export const Mobile: Story = {
33
- args: { tiles: mockFamilyTiles, all: mockFamilyTilesAll },
34
- // The grid's columns follow the viewport, not the wrapper.
35
- parameters: { viewport: { defaultViewport: "mobile1" } },
36
- decorators: [
37
- (Story) => (
38
- <div className="w-[390px] bg-background p-3">
39
- <Story />
40
- </div>
41
- ),
42
- ],
43
- };
44
-
45
- /** No photographs anywhere: every polaroid holds the family's initial. */
46
- export const WithoutPhotos: Story = {
47
- args: {
48
- tiles: mockFamilyTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
49
- all: { ...mockFamilyTilesAll, photoUrl: undefined },
50
- },
51
- };
52
-
53
- /** The drinks tab's five going-out families, no index tile. */
54
- export const Partial: Story = { args: { tiles: mockFamilyTiles.slice(0, 5) } };
@@ -1,30 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { render, screen } from "@testing-library/react";
3
- import { MenuFamilyTiles } from "./menu-family-tiles";
4
- import { mockFamilyTiles, mockFamilyTilesAll } from "./menu-mocks";
5
-
6
- describe("MenuFamilyTiles", () => {
7
- it("renders one link per family plus the index tile, in order", () => {
8
- render(<MenuFamilyTiles tiles={mockFamilyTiles} all={mockFamilyTilesAll} />);
9
- const links = screen.getAllByRole("link");
10
- expect(links).toHaveLength(mockFamilyTiles.length + 1);
11
- expect(links[0]).toHaveAttribute("href", mockFamilyTiles[0].href);
12
- expect(links.at(-1)).toHaveAttribute("href", mockFamilyTilesAll.href);
13
- expect(links.at(-1)).toHaveTextContent(mockFamilyTilesAll.label);
14
- });
15
-
16
- // A family without a photograph keeps the polaroid — the initial fills it —
17
- // so a row of seven never has one tile with a different silhouette.
18
- it("draws the initial where a family has no photograph", () => {
19
- const [withPhoto, withoutPhoto] = [mockFamilyTiles[0], mockFamilyTiles.at(-1)!];
20
- expect(withoutPhoto.photoUrl).toBeUndefined();
21
- render(<MenuFamilyTiles tiles={[withPhoto, withoutPhoto]} />);
22
- expect(document.querySelectorAll("img")).toHaveLength(1);
23
- expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
24
- });
25
-
26
- it("omits the index tile when no index is given", () => {
27
- render(<MenuFamilyTiles tiles={mockFamilyTiles.slice(0, 3)} />);
28
- expect(screen.getAllByRole("link")).toHaveLength(3);
29
- });
30
- });
@@ -1,132 +0,0 @@
1
- import { cn } from "../lib/utils";
2
- import type { MenuFamilyTile, MenuLinkComponent, MenuLinkProps } from "./menu-types";
3
- import { PhotoGround } from "./photo-ground";
4
-
5
- /**
6
- * MenuFamilyTiles — the hub's doorways: every family the city browses by, as
7
- * a grid of coloured tiles, closed by one tile that opens the whole index.
8
- *
9
- * ## A tile, not a card
10
- *
11
- * The family card this replaces was a full-width mosaic with chips, a price
12
- * band and a browse pill — a summary of the family, in a rail the reader had
13
- * to swipe through one family at a time. The tile is the family's NAME, its
14
- * venue count and one photograph on the family's hue, two to a row, so seven
15
- * families and the index fit on one phone screen. The reader is choosing a
16
- * family here, not learning about one; the summary lives on the page the tile
17
- * opens.
18
- *
19
- * ## The photograph is a polaroid, not a ground
20
- *
21
- * One square photograph, tilted, running off the tile's top and right edges
22
- * — the tile's `overflow-hidden` crops it, so it reads as a print dropped on
23
- * the tile rather than a thumbnail placed in it — with the name and the count
24
- * in the bottom-left, under the print's lower edge. Not a ground: white type
25
- * on a photograph needs a scrim heavy enough to hide the photograph, and the
26
- * hue is what tells seven tiles apart at a glance. The photograph is the
27
- * FAMILY's — a plate, a room, a glass — served from the consumer's own origin,
28
- * never a venue's (a venue on a tile about every venue reads as an
29
- * endorsement). A family with no photograph gets the same tilted square
30
- * holding `PhotoGround`'s serif initial, so the silhouette never changes.
31
- *
32
- * Every measure on the polaroid is a percentage of the tile, so the tile and
33
- * the design stay in proportion from a 170px phone cell to a 260px desktop
34
- * one: 82% of the height, 8% above the top, 4% past the right, 8° over —
35
- * measured off the design frame.
36
- *
37
- * The bottom scrim is mandatory. The honey and terrace hues sit around
38
- * `oklch(0.6…)`, where white at 14.5px is about 3:1 — under the 4.5:1 the
39
- * label needs. The scrim takes the label's corner down to where it passes on
40
- * every hue in the dictionary.
41
- *
42
- * Server-safe.
43
- */
44
-
45
- function DefaultLink({ href, ...props }: MenuLinkProps) {
46
- return <a href={href} {...props} />;
47
- }
48
-
49
- export type MenuFamilyTilesProps = {
50
- tiles: MenuFamilyTile[];
51
- /**
52
- * The closing tile: the index behind the seven, drawn exactly like them —
53
- * its own hue and photograph, `countLabel` as the caption. Only its place in
54
- * the grid says it is the index. Omit when there is none.
55
- */
56
- all?: MenuFamilyTile;
57
- linkComponent?: MenuLinkComponent;
58
- className?: string;
59
- };
60
-
61
- /**
62
- * The tile: the design's 16:10, never shorter than two lines of name plus the
63
- * count need.
64
- */
65
- const TILE =
66
- "group relative isolate flex aspect-[16/10] min-h-[108px] flex-col justify-end overflow-hidden rounded-2xl bg-muted p-3.5 text-white lg:p-4 " +
67
- "transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card-hover)] active:translate-y-0 active:scale-[0.99] motion-reduce:transition-none " +
68
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
69
-
70
- /**
71
- * The polaroid. Same literal family as `PHOTO_GROUND` for the shadow, never a
72
- * black `rgba` — the shadow is the ground the card family already stands on.
73
- */
74
- const POLAROID =
75
- "absolute -right-[4%] -top-[8%] z-0 block aspect-square h-[82%] overflow-hidden rounded-[10%] rotate-[8deg] " +
76
- "shadow-[0_10px_22px_-6px_oklch(0.16_0.012_55_/_0.65)] transition-transform duration-300 group-hover:rotate-[5deg] motion-reduce:transition-none";
77
-
78
- const SCRIM =
79
- "after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-3/5 after:bg-gradient-to-t after:from-[oklch(0.16_0.012_55_/_0.55)] after:to-transparent after:content-['']";
80
-
81
- export function MenuFamilyTiles({
82
- tiles,
83
- all,
84
- linkComponent: LinkComponent = DefaultLink,
85
- className,
86
- }: MenuFamilyTilesProps) {
87
- const cells = all ? [...tiles, all] : tiles;
88
-
89
- return (
90
- <div className={cn("grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5", className)}>
91
- {cells.map((tile) => (
92
- <LinkComponent
93
- key={tile.slug}
94
- href={tile.href}
95
- className={cn(TILE, SCRIM)}
96
- style={tile.gradient ? { backgroundImage: tile.gradient } : undefined}
97
- >
98
- <span aria-hidden className={POLAROID}>
99
- {tile.photoUrl ? (
100
- /* Not lazy: the grid sits directly under the hero, and these
101
- photographs are the first thing the reader looks at. */
102
- // eslint-disable-next-line @next/next/no-img-element
103
- <img
104
- src={tile.photoUrl}
105
- alt=""
106
- decoding="async"
107
- className="absolute inset-0 h-full w-full object-cover"
108
- />
109
- ) : (
110
- <PhotoGround
111
- photos={[undefined]}
112
- initials={tile.label.charAt(0)}
113
- mode="stack"
114
- glyphClassName="text-[44px]"
115
- />
116
- )}
117
- </span>
118
-
119
- {/* Both lines keep to the left 55%: the polaroid owns the right. */}
120
- <span className="relative z-[1] line-clamp-2 max-w-[55%] text-[14.5px] font-extrabold leading-[1.2] tracking-[-0.015em] lg:text-[16px]">
121
- {tile.label}
122
- </span>
123
- {tile.countLabel && (
124
- <span className="relative z-[1] mt-0.5 max-w-[55%] truncate text-[12px] font-medium leading-[1.35] tabular-nums text-white/80 lg:text-[13px]">
125
- {tile.countLabel}
126
- </span>
127
- )}
128
- </LinkComponent>
129
- ))}
130
- </div>
131
- );
132
- }