@bearmenu/ui 0.8.9 → 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 (178) 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-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  5. package/dist/chunk-CLLY6XB3.js +41 -0
  6. package/dist/chunk-DTXNDLSE.js +125 -0
  7. package/dist/chunk-F6FMZJDG.js +113 -0
  8. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  9. package/dist/chunk-MZL7CGCL.js +109 -0
  10. package/dist/chunk-SY6RV476.js +48 -0
  11. package/dist/chunk-UDN7UHJW.js +60 -0
  12. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/command.d.ts +7 -7
  16. package/dist/components/concept-compare-rail.d.ts +50 -0
  17. package/dist/components/concept-compare-rail.js +5 -0
  18. package/dist/components/concept-compare.d.ts +75 -38
  19. package/dist/components/concept-compare.js +209 -133
  20. package/dist/components/concept-convergence.d.ts +41 -0
  21. package/dist/components/concept-convergence.js +53 -0
  22. package/dist/components/convergence.d.ts +25 -0
  23. package/dist/components/convergence.js +4 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-index-row.js +1 -1
  29. package/dist/components/event-list-row-wide.js +1 -1
  30. package/dist/components/event-list-row.js +2 -2
  31. package/dist/components/explainer-steps.d.ts +34 -0
  32. package/dist/components/explainer-steps.js +36 -0
  33. package/dist/components/glass-ground.d.ts +66 -0
  34. package/dist/components/glass-ground.js +3 -0
  35. package/dist/components/hub-surface.js +11 -1
  36. package/dist/components/hue-chip.d.ts +70 -0
  37. package/dist/components/hue-chip.js +3 -0
  38. package/dist/components/kitchen-dossier.d.ts +88 -0
  39. package/dist/components/kitchen-dossier.js +187 -0
  40. package/dist/components/menu-dead-end.d.ts +56 -0
  41. package/dist/components/menu-dead-end.js +49 -0
  42. package/dist/components/menu-family-block.d.ts +99 -0
  43. package/dist/components/menu-family-block.js +155 -0
  44. package/dist/components/menu-family-card.d.ts +33 -27
  45. package/dist/components/menu-family-card.js +43 -85
  46. package/dist/components/menu-field.js +92 -2
  47. package/dist/components/menu-filter-chips.d.ts +86 -0
  48. package/dist/components/menu-filter-chips.js +122 -0
  49. package/dist/components/menu-group-rows.d.ts +53 -0
  50. package/dist/components/menu-group-rows.js +67 -0
  51. package/dist/components/menu-index-header.d.ts +37 -0
  52. package/dist/components/menu-index-header.js +68 -0
  53. package/dist/components/menu-index-list.d.ts +48 -0
  54. package/dist/components/menu-index-list.js +96 -0
  55. package/dist/components/menu-intent-hero.d.ts +69 -0
  56. package/dist/components/menu-intent-hero.js +87 -0
  57. package/dist/components/menu-kitchen-list.d.ts +82 -0
  58. package/dist/components/menu-kitchen-list.js +5 -0
  59. package/dist/components/menu-mocks.d.ts +37 -2
  60. package/dist/components/menu-mocks.js +152 -27
  61. package/dist/components/menu-types.d.ts +182 -6
  62. package/dist/components/motion-icon-controls.js +2 -2
  63. package/dist/components/motion-icon-set.js +6 -6
  64. package/dist/components/motion-icon.d.ts +22 -1
  65. package/dist/components/motion-icon.js +1 -1
  66. package/dist/components/photo-ground.d.ts +64 -0
  67. package/dist/components/photo-ground.js +3 -0
  68. package/dist/components/place-about-tab.js +2 -2
  69. package/dist/components/place-details-mobile.js +2 -2
  70. package/dist/components/place-types.d.ts +2 -2
  71. package/dist/components/qa-block.d.ts +36 -0
  72. package/dist/components/qa-block.js +24 -0
  73. package/dist/components/scroll-fade.js +3 -121
  74. package/dist/components/snap-rail.d.ts +24 -1
  75. package/dist/components/snap-rail.js +3 -109
  76. package/dist/components/spinner.d.ts +2 -2
  77. package/dist/components/story.d.ts +34 -0
  78. package/dist/components/story.js +3 -0
  79. package/dist/components/text.d.ts +3 -3
  80. package/dist/components/top-comparison.d.ts +28 -0
  81. package/dist/components/top-comparison.js +76 -0
  82. package/dist/components/top-cross-link.d.ts +34 -0
  83. package/dist/components/top-cross-link.js +101 -0
  84. package/dist/components/top-ranking-entry.d.ts +53 -0
  85. package/dist/components/top-ranking-entry.js +217 -0
  86. package/dist/components/top-ranking-hero.d.ts +61 -0
  87. package/dist/components/top-ranking-hero.js +99 -0
  88. package/dist/components/top-ranking-legend.d.ts +69 -0
  89. package/dist/components/top-ranking-legend.js +124 -0
  90. package/dist/components/top-types.d.ts +109 -0
  91. package/dist/components/top-types.js +1 -0
  92. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  93. package/dist/components/venue-grade-dimensions.js +1 -1
  94. package/dist/components/weekend-panel.js +2 -2
  95. package/package.json +1 -1
  96. package/src/components/concept-compare-rail.tsx +131 -0
  97. package/src/components/concept-compare.stories.tsx +122 -54
  98. package/src/components/concept-compare.tsx +363 -196
  99. package/src/components/concept-convergence.stories.tsx +53 -0
  100. package/src/components/concept-convergence.tsx +90 -0
  101. package/src/components/convergence.stories.tsx +54 -0
  102. package/src/components/convergence.tsx +177 -0
  103. package/src/components/explainer-steps.stories.tsx +46 -0
  104. package/src/components/explainer-steps.tsx +74 -0
  105. package/src/components/glass-ground.tsx +129 -0
  106. package/src/components/hub-surface.tsx +11 -1
  107. package/src/components/hue-chip.stories.tsx +76 -0
  108. package/src/components/hue-chip.test.tsx +62 -0
  109. package/src/components/hue-chip.tsx +99 -0
  110. package/src/components/kitchen-dossier.stories.tsx +108 -0
  111. package/src/components/kitchen-dossier.tsx +322 -0
  112. package/src/components/menu-dead-end.stories.tsx +67 -0
  113. package/src/components/menu-dead-end.tsx +127 -0
  114. package/src/components/menu-family-block.stories.tsx +91 -0
  115. package/src/components/menu-family-block.tsx +314 -0
  116. package/src/components/menu-family-card.stories.tsx +43 -35
  117. package/src/components/menu-family-card.tsx +122 -142
  118. package/src/components/menu-filter-chips.stories.tsx +107 -0
  119. package/src/components/menu-filter-chips.tsx +238 -0
  120. package/src/components/menu-group-rows.stories.tsx +54 -0
  121. package/src/components/menu-group-rows.tsx +133 -0
  122. package/src/components/menu-index-header.stories.tsx +72 -0
  123. package/src/components/menu-index-header.tsx +125 -0
  124. package/src/components/menu-index-list.stories.tsx +50 -0
  125. package/src/components/menu-index-list.tsx +149 -0
  126. package/src/components/menu-intent-hero.stories.tsx +67 -0
  127. package/src/components/menu-intent-hero.tsx +189 -0
  128. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  129. package/src/components/menu-kitchen-list.test.tsx +78 -0
  130. package/src/components/menu-kitchen-list.tsx +284 -0
  131. package/src/components/menu-mocks.ts +198 -23
  132. package/src/components/menu-types.ts +189 -6
  133. package/src/components/motion-icon-controls.tsx +11 -11
  134. package/src/components/motion-icon-set.tsx +6 -5
  135. package/src/components/motion-icon.test.tsx +23 -1
  136. package/src/components/motion-icon.tsx +20 -0
  137. package/src/components/photo-ground.tsx +147 -0
  138. package/src/components/place-types.ts +2 -2
  139. package/src/components/qa-block.stories.tsx +43 -0
  140. package/src/components/qa-block.tsx +60 -0
  141. package/src/components/snap-rail.tsx +58 -15
  142. package/src/components/story.tsx +225 -0
  143. package/src/components/top-comparison.stories.tsx +78 -0
  144. package/src/components/top-comparison.tsx +151 -0
  145. package/src/components/top-cross-link.stories.tsx +84 -0
  146. package/src/components/top-cross-link.tsx +171 -0
  147. package/src/components/top-ranking-entry.stories.tsx +171 -0
  148. package/src/components/top-ranking-entry.test.tsx +105 -0
  149. package/src/components/top-ranking-entry.tsx +425 -0
  150. package/src/components/top-ranking-hero.stories.tsx +76 -0
  151. package/src/components/top-ranking-hero.tsx +218 -0
  152. package/src/components/top-ranking-legend.stories.tsx +78 -0
  153. package/src/components/top-ranking-legend.tsx +239 -0
  154. package/src/components/top-types.ts +110 -0
  155. package/src/components/venue-grade-dimensions.tsx +2 -2
  156. package/dist/chunk-THOVPZNN.js +0 -37
  157. package/dist/chunk-YS6BHKZO.js +0 -92
  158. package/dist/components/ranked-venue-list.d.ts +0 -49
  159. package/dist/components/ranked-venue-list.js +0 -87
  160. package/dist/components/signature-line-list.d.ts +0 -45
  161. package/dist/components/signature-line-list.js +0 -99
  162. package/dist/components/venue-busyness-strip.d.ts +0 -35
  163. package/dist/components/venue-busyness-strip.js +0 -37
  164. package/dist/components/venue-signature-block.d.ts +0 -39
  165. package/dist/components/venue-signature-block.js +0 -6
  166. package/dist/components/venue-spotlight.d.ts +0 -74
  167. package/dist/components/venue-spotlight.js +0 -138
  168. package/src/components/ranked-venue-list.stories.tsx +0 -80
  169. package/src/components/ranked-venue-list.tsx +0 -177
  170. package/src/components/signature-line-list.stories.tsx +0 -85
  171. package/src/components/signature-line-list.tsx +0 -136
  172. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  173. package/src/components/venue-busyness-strip.tsx +0 -64
  174. package/src/components/venue-signature-block.stories.tsx +0 -68
  175. package/src/components/venue-signature-block.tsx +0 -71
  176. package/src/components/venue-spotlight.stories.tsx +0 -141
  177. package/src/components/venue-spotlight.tsx +0 -236
  178. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,124 @@
1
+ import { cn } from '../chunk-FEK5XGZW.js';
2
+ import '../chunk-3GWWZKX7.js';
3
+ import { useState, useEffect } from 'react';
4
+ import { jsxs, jsx } from 'react/jsx-runtime';
5
+
6
+ var ACTIVE_BAND = "-20% 0px -75% 0px";
7
+ function TopRankingLegend({ groups, heading, meta, className }) {
8
+ const entries = groups.flatMap((group) => group.entries);
9
+ const [activeSlug, setActiveSlug] = useState(null);
10
+ useEffect(() => {
11
+ if (typeof IntersectionObserver === "undefined") return void 0;
12
+ const nodes = entries.map((entry) => document.getElementById(entry.slug)).filter((node) => Boolean(node));
13
+ if (nodes.length === 0) return void 0;
14
+ const observer = new IntersectionObserver(
15
+ (observed) => {
16
+ const inBand = observed.filter((record) => record.isIntersecting);
17
+ if (inBand.length === 0) return;
18
+ const topmost = inBand.reduce(
19
+ (best, record) => record.boundingClientRect.top < best.boundingClientRect.top ? record : best
20
+ );
21
+ setActiveSlug(topmost.target.id);
22
+ },
23
+ { rootMargin: ACTIVE_BAND }
24
+ );
25
+ nodes.forEach((node) => observer.observe(node));
26
+ return () => observer.disconnect();
27
+ }, [entries.map((entry) => entry.slug).join("|")]);
28
+ return (
29
+ /*
30
+ * The height cap is a safety net, not the navigation model.
31
+ *
32
+ * `position: sticky` on an element TALLER than the viewport leaves its
33
+ * bottom permanently unreachable — at twenty-five rows on a 900px laptop
34
+ * you could never see ranks 20–25, however you scrolled. That is broken
35
+ * rather than merely tight, so the column scrolls internally once it
36
+ * exceeds the space available.
37
+ *
38
+ * It only ever engages on a viewport too short to fit the list: `max-height`
39
+ * with `overflow-y-auto` is inert when the content already fits, so nothing
40
+ * changes on a tall screen. `overscroll-contain` stops a flick inside the
41
+ * column from carrying on into the article behind it.
42
+ */
43
+ /* @__PURE__ */ jsxs(
44
+ "nav",
45
+ {
46
+ "aria-label": heading,
47
+ className: cn(
48
+ /*
49
+ * Sticky at BOTH widths now, with a different offset and a different
50
+ * shape at each.
51
+ *
52
+ * On desktop it is the grouped column, pinned beside the article. On a
53
+ * phone it is the flat chip rail, pinned for the same reason the column
54
+ * is: this page is twenty-five long entries, so a reader at rank 14 is
55
+ * several screens from any means of jumping, and the rail scrolled away
56
+ * inside the first entry.
57
+ *
58
+ * The phone offset comes from `--legend-sticky-top`, which the CALLER
59
+ * sets, because it is that app's chrome — the consumer app pins under a
60
+ * fixed header at `calc(var(--header-offset) + env(safe-area-inset-top))`,
61
+ * exactly as its events day rail does. Reading the app's own
62
+ * `--header-offset` here would resolve to nothing in Storybook and in
63
+ * the other three consumers, and an invalid `calc` silently kills
64
+ * `top`, which turns the rail from mispositioned into not-sticky.
65
+ */
66
+ "sticky top-[var(--legend-sticky-top,0px)] z-20 lg:top-4 lg:z-auto",
67
+ "lg:max-h-[calc(100vh-2rem)] lg:self-start lg:overflow-y-auto lg:overscroll-contain",
68
+ "lg:[scrollbar-width:thin]",
69
+ className
70
+ ),
71
+ children: [
72
+ /* @__PURE__ */ jsxs("div", { className: "hidden lg:block", children: [
73
+ /* @__PURE__ */ jsx("h2", { className: "text-[15px] font-bold tracking-[-0.01em]", children: heading }),
74
+ meta && /* @__PURE__ */ jsx("p", { className: "mt-1 text-[11.5px] text-muted-foreground", children: meta })
75
+ ] }),
76
+ /* @__PURE__ */ jsx("ul", { className: "scrollbar-hide -mx-[var(--page-gutter)] flex gap-2 overflow-x-auto bg-background px-[var(--page-gutter)] pb-3 pt-1 lg:hidden", children: entries.map((entry) => /* @__PURE__ */ jsx("li", { className: "shrink-0", children: /* @__PURE__ */ jsxs(
77
+ "a",
78
+ {
79
+ href: `#${entry.slug}`,
80
+ className: "inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-full border border-border bg-card px-3.5 text-[13px] transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
81
+ children: [
82
+ /* @__PURE__ */ jsx("span", { className: "font-bold tabular-nums text-muted-foreground", children: entry.rank }),
83
+ entry.name
84
+ ]
85
+ }
86
+ ) }, entry.slug)) }),
87
+ /* @__PURE__ */ jsx("div", { className: "hidden lg:mt-5 lg:-mx-3 lg:block", children: groups.map((group) => /* @__PURE__ */ jsxs("div", { className: "mb-4 last:mb-0", children: [
88
+ /* @__PURE__ */ jsx("p", { className: "px-3 pb-1.5 text-[10.5px] font-bold uppercase tracking-[0.11em] text-muted-foreground", children: group.label }),
89
+ /* @__PURE__ */ jsx("ul", { children: group.entries.map((entry) => {
90
+ const isActive = entry.slug === activeSlug;
91
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
92
+ "a",
93
+ {
94
+ href: `#${entry.slug}`,
95
+ "aria-current": isActive ? "location" : void 0,
96
+ className: cn(
97
+ "flex gap-2.5 rounded-lg px-3 py-2 text-[13px] leading-snug transition-colors motion-reduce:transition-none",
98
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
99
+ isActive ? "bg-accent-subtle font-semibold text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
100
+ ),
101
+ children: [
102
+ /* @__PURE__ */ jsx(
103
+ "span",
104
+ {
105
+ className: cn(
106
+ "w-5 shrink-0 text-right font-bold tabular-nums",
107
+ isActive ? "text-accent" : "text-muted-foreground/70"
108
+ ),
109
+ children: entry.rank
110
+ }
111
+ ),
112
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1", children: entry.name })
113
+ ]
114
+ }
115
+ ) }, entry.slug);
116
+ }) })
117
+ ] }, group.key)) })
118
+ ]
119
+ }
120
+ )
121
+ );
122
+ }
123
+
124
+ export { TopRankingLegend };
@@ -0,0 +1,109 @@
1
+ import { ReactNode } from 'react';
2
+ import { MenuItemPreview } from './menu-types.js';
3
+ import './place-types.js';
4
+
5
+ /**
6
+ * The editorial ranking's view model — `/top/[category]`.
7
+ *
8
+ * Separate from `menu-types.ts` on purpose. The menu surfaces describe what a
9
+ * city HAS; these describe an argument the product is making about it, and the
10
+ * shapes reflect that: an entry carries a cited quote and a dated price because
11
+ * a rank is a claim and the page has to show why.
12
+ *
13
+ * Same contract as everywhere else: no app schemas, no hooks, no i18n. Strings
14
+ * arrive through labels, destinations through `href`, numbers through injected
15
+ * formatters.
16
+ */
17
+
18
+ /** One entry in the ranking. */
19
+ type TopRankEntry = {
20
+ rank: number;
21
+ slug: string;
22
+ name: string;
23
+ /** The venue page. */
24
+ href: string;
25
+ /** Neighbourhood, e.g. "Centru". */
26
+ district?: string;
27
+ /** Pre-resolved venue photograph. */
28
+ coverUrl?: string;
29
+ /** The app's grade, already formatted — "9.4". Absent where none exists. */
30
+ gradeLabel?: string;
31
+ /** The band that grade falls in, e.g. "Exceptional". Composed by the caller. */
32
+ tierLabel?: string;
33
+ /** Third-party star rating, shown only where there is no grade. */
34
+ rating?: number;
35
+ /**
36
+ * The editorial line — why this one is here, in the product's own voice.
37
+ *
38
+ * Distinct from `evidence`, which is a reviewer's words. Conflating them was
39
+ * the old page's problem: a quote presented as editorial reads as invented,
40
+ * and editorial presented as a quote reads as dishonest.
41
+ */
42
+ editorial?: string;
43
+ /**
44
+ * The dishes this venue is named for, as PRODUCTION MENU ROWS.
45
+ *
46
+ * `MenuItemPreview`, so the entry renders the same `<MenuItem>` a venue's own
47
+ * menu and every hub surface renders. The first pass here hand-rolled a row —
48
+ * a bold name, a muted description, a price — which drifted from the real one
49
+ * immediately and meant a dish looked like a different object on this page
50
+ * than on every other.
51
+ *
52
+ * More than one, because a rank is a claim about a kitchen and one dish
53
+ * rarely carries it: the live page shows the group and two or three of its
54
+ * most-named plates.
55
+ */
56
+ menuItems?: MenuItemPreview[];
57
+ /** The group those items sit in, e.g. "Pizza". From the venue's own menu. */
58
+ menuGroupLabel?: string;
59
+ /** A reviewer's own words, and how many said it. */
60
+ evidence?: TopEvidence;
61
+ };
62
+ type TopEvidence = {
63
+ /**
64
+ * A reviewer's own words. OPTIONAL: a venue can carry a mention count with no
65
+ * quotable snippet behind it, and the count is evidence in its own right —
66
+ * dropping the whole block for want of a quote loses the number that
67
+ * justifies the rank.
68
+ */
69
+ quote?: string;
70
+ /**
71
+ * How many reviews back it — the figure alone, e.g. "37". Rendered as its own
72
+ * element beside `sourceLabel` so the number reads as a figure rather than as
73
+ * the first word of a sentence.
74
+ */
75
+ count?: string;
76
+ /** What the count is evidence OF, e.g. "reviews praise the shawarma here". */
77
+ sourceLabel: string;
78
+ /**
79
+ * The rest of the cited reviews, behind a disclosure.
80
+ *
81
+ * A `<details>`, not a drawer, and that is the whole point: its contents stay
82
+ * in the HTML, so the evidence is crawlable and readable with no JavaScript.
83
+ * A page whose entire argument is "here is why" cannot ship most of the why
84
+ * in a modal.
85
+ */
86
+ extraQuotes?: string[];
87
+ /** Summary text for the disclosure, e.g. "2 more reviews". Pluralised by the caller. */
88
+ extraLabel?: string;
89
+ };
90
+ /** One row of the side-by-side comparison. */
91
+ type TopComparisonRow = {
92
+ rank: number;
93
+ name: string;
94
+ href: string;
95
+ gradeLabel?: string;
96
+ signature?: string;
97
+ priceLabel?: string;
98
+ /** Kitchen serving past midnight. */
99
+ late?: boolean;
100
+ terrace?: boolean;
101
+ };
102
+ /** A way out of the article and into the browsing tool. */
103
+ type TopExit = {
104
+ label: string;
105
+ href: string;
106
+ icon?: ReactNode;
107
+ };
108
+
109
+ export type { TopComparisonRow, TopEvidence, TopExit, TopRankEntry };
@@ -0,0 +1 @@
1
+
@@ -21,6 +21,8 @@ import { PlaceGradeDimensions } from './place-types.js';
21
21
  * Server-safe.
22
22
  */
23
23
  type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
24
+ /** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
25
+ declare const DIMENSION_ORDER: VenueGradeDimensionKey[];
24
26
  type VenueGradeDimensionsProps = {
25
27
  dimensions: PlaceGradeDimensions;
26
28
  /** Dimension key → translated label. Keys with no label fall back to the key. */
@@ -33,4 +35,4 @@ type VenueGradeDimensionsProps = {
33
35
  };
34
36
  declare function VenueGradeDimensions({ dimensions, labels, order, formatScore, className, }: VenueGradeDimensionsProps): react_jsx_runtime.JSX.Element | null;
35
37
 
36
- export { type VenueGradeDimensionKey, VenueGradeDimensions, type VenueGradeDimensionsProps };
38
+ export { DIMENSION_ORDER, type VenueGradeDimensionKey, VenueGradeDimensions, type VenueGradeDimensionsProps };
@@ -1,3 +1,3 @@
1
- export { VenueGradeDimensions } from '../chunk-AXV5O2IZ.js';
1
+ export { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-AKQ57NXY.js';
2
2
  import '../chunk-FEK5XGZW.js';
3
3
  import '../chunk-3GWWZKX7.js';
@@ -1,6 +1,6 @@
1
1
  import { useMediaQuery, DESKTOP_MEDIA_QUERY } from '../chunk-OPVKL4VS.js';
2
- import { MotionPlayPause } from '../chunk-TT3WFS6S.js';
3
- import '../chunk-2WDJR6K7.js';
2
+ import { MotionPlayPause } from '../chunk-YOKHBAIH.js';
3
+ import '../chunk-G7IFL4Z4.js';
4
4
  import { useDragSwipe } from '../chunk-IYDH5IU6.js';
5
5
  import { Skeleton } from '../chunk-QXHMPJ35.js';
6
6
  import { cn } from '../chunk-FEK5XGZW.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { PhotoGround } from "./photo-ground";
6
+ import { SnapRail, SnapRailDots } from "./snap-rail";
7
+
8
+ /**
9
+ * ConceptCompareRail — the rooms as a rail, the card's backdrop following the
10
+ * active room, and the foot band that carries the page dots.
11
+ *
12
+ * On the phone the card IS the current room's photograph: the head, the rail
13
+ * and the foot all sit on it, and it cross-fades to the next room as the
14
+ * reader swipes. That needs the rail's active index, which is state — so this
15
+ * is the one client boundary in `ConceptCompare`. It takes only what can cross
16
+ * that boundary: photograph URLs, initials, and the pre-rendered rooms and
17
+ * foot as children. `labels`, `formatPrice` and the rest stay server-side.
18
+ *
19
+ * ## Why the backdrop lives here and not in each slide
20
+ *
21
+ * A slide carrying its own photograph is a boxed card sliding over a dark
22
+ * ground; the backdrop changing under a transparent slide is the reader
23
+ * turning to look at the next room. The second is the smoother thing, and it
24
+ * is also cheaper to look at — one photograph at a time instead of the edge of
25
+ * the next one peeking in.
26
+ *
27
+ * ## Why the foot is here too
28
+ *
29
+ * The dots need the rail's state, and on the phone they belong INSIDE the foot
30
+ * band's glass — under the rail they sat on a strip of bare photograph between
31
+ * the room's scrim and the foot's. So the foot renders here, with the dots as
32
+ * its first row below `lg`, and `ConceptCompare` hands it the foot's content.
33
+ *
34
+ * Desktop ignores the backdrop and the dots (`lg:hidden`); each room carries
35
+ * its own photograph there, side by side, and the foot is an absolute glass
36
+ * band over them.
37
+ */
38
+
39
+ export type ConceptCompareBackdrop = {
40
+ /** The room's photograph. Absent: the ground with the venue's initial. */
41
+ src?: string;
42
+ /** The venue's first letter, for the no-photograph fallback. */
43
+ initial: string;
44
+ };
45
+
46
+ export type ConceptCompareRailProps = {
47
+ backdrops: ConceptCompareBackdrop[];
48
+ /** Classes for the rail itself — the desktop grid lives here. */
49
+ railClassName?: string;
50
+ /** The foot's content: the note and the action. Omit for no foot. */
51
+ foot?: React.ReactNode;
52
+ children: React.ReactNode;
53
+ };
54
+
55
+ /**
56
+ * The foot band's glass — two behaviours, one element.
57
+ *
58
+ * On the phone the band sits directly under a slide whose own scrim has
59
+ * already reached 0.9 at its bottom edge, so the glass is SOLID from its first
60
+ * pixel: a fade-in here left a lighter strip at the seam where the photograph
61
+ * showed through between the slide's darkness and the foot's, and the two read
62
+ * as disconnected pieces. From `lg` the foot floats over open photography with
63
+ * the room text well above it, so there it fades out over its top edge on the
64
+ * same curve as its mask, the way the head does at its underside.
65
+ *
66
+ * Same literal as every other scrim on the card, so they read as one shadow;
67
+ * not `var(--scrim)`, which is the app's token and not this package's.
68
+ */
69
+ const FOOT_GLASS = cn(
70
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg",
71
+ "before:bg-[oklch(0.16_0.012_55_/_0.9)]",
72
+ "lg:before:[mask-image:linear-gradient(to_top,black_60%,transparent_100%)] lg:before:bg-[linear-gradient(to_top,oklch(0.16_0.012_55_/_0.88)_0%,oklch(0.16_0.012_55_/_0.84)_60%,oklch(0.16_0.012_55_/_0.4)_85%,oklch(0.16_0.012_55_/_0)_100%)]"
73
+ );
74
+
75
+ export function ConceptCompareRail({
76
+ backdrops,
77
+ railClassName,
78
+ foot,
79
+ children,
80
+ }: ConceptCompareRailProps) {
81
+ const [active, setActive] = React.useState(0);
82
+ const count = React.Children.count(children);
83
+
84
+ return (
85
+ <>
86
+ {/* The backdrop: every room's photograph stacked, the active one shown,
87
+ cross-fading on swipe. Positioned against the CARD — `ConceptCompare`
88
+ renders this inside its `relative isolate` frame. Desktop hides it;
89
+ each room carries its own photograph there. */}
90
+ <PhotoGround
91
+ className="-z-10 lg:hidden"
92
+ mode="stack"
93
+ active={active}
94
+ photos={backdrops.map((backdrop) => backdrop.src)}
95
+ initials={backdrops.map((backdrop) => backdrop.initial)}
96
+ overlay
97
+ loading="eager"
98
+ />
99
+
100
+ {/* `pb-0` over the rail's default `pb-0.5`: two pixels of half-inked
101
+ photograph between the slide's scrim and the foot's glass reads as a
102
+ hairline where none was drawn.
103
+
104
+ `overscroll-x-contain`, because this card is itself a slide in the
105
+ hub's concept carousel. Without it a swipe that reaches the last room
106
+ chains into the outer rail and jumps to the next concept — the reader
107
+ asked to see the fourth kitchen and got a different dish. */}
108
+ <SnapRail
109
+ itemWidth="full"
110
+ gap="sm"
111
+ onActiveChange={setActive}
112
+ className={cn("pb-0 overscroll-x-contain", railClassName)}
113
+ >
114
+ {children}
115
+ </SnapRail>
116
+
117
+ {foot && (
118
+ <div
119
+ className={cn(
120
+ "relative isolate flex flex-col gap-3 px-4 pb-4 pt-3",
121
+ "lg:absolute lg:inset-x-0 lg:bottom-0 lg:z-10 lg:flex-row lg:items-center lg:gap-5 lg:px-5 lg:pb-4 lg:pt-6",
122
+ FOOT_GLASS
123
+ )}
124
+ >
125
+ <SnapRailDots count={count} active={active} className="lg:hidden" />
126
+ {foot}
127
+ </div>
128
+ )}
129
+ </>
130
+ );
131
+ }
@@ -1,35 +1,108 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Button } from "./button";
3
2
  import { ConceptCompare } from "./concept-compare";
4
- import { mockConcept, mockGradients } from "./menu-mocks";
3
+ import { mockConcept } from "./menu-mocks";
4
+ import type { MenuConceptRow } from "./menu-types";
5
5
 
6
6
  const labels = {
7
7
  kind: "DISH CONCEPT",
8
- alias: "ON THEIR MENU AS",
9
- perPortion: "per portion",
10
- cheapest: "cheapest shown",
11
- };
12
-
13
- const itemLabels = {
14
- currency: "lei",
8
+ cheapest: "Cheapest",
15
9
  mentions: (count: number) => `${count} mentions`,
16
- signature: "SIGNATURE",
17
- mentioned: "MENTIONED",
10
+ openRoom: (alias: string, venue: string) => `${alias} at ${venue}`,
18
11
  };
19
12
 
20
13
  const formatPrice = (amount: number) => `${amount} lei`;
21
14
 
22
- const hrefs = Object.fromEntries(
23
- (mockConcept.rows ?? []).map((row) => [row.place_slug, `/places/${row.place_slug}`])
15
+ /** A stand-in venue photograph with enough variance that the glass has something to blur. */
16
+ function room(hue: number, label: string) {
17
+ return (
18
+ "data:image/svg+xml;utf8," +
19
+ encodeURIComponent(
20
+ `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="640"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 28% 60%)"/><stop offset="1" stop-color="hsl(${hue} 22% 26%)"/></linearGradient></defs><rect width="480" height="640" fill="url(#g)"/><circle cx="140" cy="150" r="70" fill="hsl(${hue} 40% 82%)" opacity=".55"/><rect x="240" y="360" width="200" height="140" fill="hsl(${hue} 15% 18%)" opacity=".7"/><text x="50%" y="92%" font-family="monospace" font-size="20" fill="#fff" text-anchor="middle">${label}</text></svg>`
21
+ )
22
+ );
23
+ }
24
+
25
+ const rooms: MenuConceptRow[] = [
26
+ {
27
+ place_slug: "casa-maramureseana",
28
+ place_name: "Casa Maramureșeană",
29
+ alias: "Papanași cu Smântână și Dulceață",
30
+ price: 29,
31
+ mentions: 12,
32
+ rating: 4.5,
33
+ place_area: "Centru",
34
+ place_thumbnail_url: room(30, "room 1"),
35
+ },
36
+ {
37
+ place_slug: "maimuta-plangatoare",
38
+ place_name: "Maimuța Plângătoare",
39
+ alias: "Papanasi cu Gem si Smantana",
40
+ price: 31,
41
+ mentions: 19,
42
+ rating: 4.5,
43
+ place_area: "Centru",
44
+ place_thumbnail_url: room(200, "room 2"),
45
+ },
46
+ {
47
+ place_slug: "cafe-bulgakov",
48
+ place_name: "Café Bulgakov",
49
+ alias: "Papanași",
50
+ price: 32,
51
+ mentions: 12,
52
+ rating: 4.6,
53
+ place_area: "Centru",
54
+ place_thumbnail_url: room(90, "room 3"),
55
+ },
56
+ {
57
+ place_slug: "garlic",
58
+ place_name: "GARLIC - bites&tales",
59
+ alias: "Papanași în Țeapă",
60
+ price: 38,
61
+ mentions: 12,
62
+ rating: 4.7,
63
+ place_area: "Centru",
64
+ place_thumbnail_url: room(300, "room 4"),
65
+ },
66
+ ];
67
+
68
+ const concept = { ...mockConcept, slug: "papanasi", title: "Papanași", rows: rooms };
69
+
70
+ const figures = [
71
+ { value: "45", label: "kitchens" },
72
+ { value: "29–52", label: "lei across them", accent: true },
73
+ { value: "167", label: "mentions" },
74
+ { value: "8", label: "names" },
75
+ ];
76
+
77
+ const hrefs = Object.fromEntries(rooms.map((r) => [r.place_slug, `/places/${r.place_slug}`]));
78
+
79
+ const seeAll = (
80
+ <a
81
+ href="/menu/dishes/papanasi"
82
+ className="inline-flex h-10 items-center justify-center gap-1.5 rounded-full border border-white/20 bg-white/[0.12] px-4 text-[13.5px] font-bold text-white backdrop-blur-md"
83
+ >
84
+ See all 45 kitchens
85
+ </a>
24
86
  );
25
87
 
26
88
  const meta: Meta<typeof ConceptCompare> = {
27
89
  title: "Menu/ConceptCompare",
28
90
  component: ConceptCompare,
29
91
  tags: ["autodocs"],
92
+ args: {
93
+ concept,
94
+ labels,
95
+ lede: "45 kitchens make it, and they do not agree on the name",
96
+ formatPrice,
97
+ figures,
98
+ hrefs,
99
+ footnote:
100
+ "The four cheapest of the 45, priced from each menu as it stands — the dearest is 52 lei. One photograph per kitchen; no room here is a room that does not serve it.",
101
+ actions: seeAll,
102
+ },
30
103
  decorators: [
31
104
  (Story) => (
32
- <div className="w-[1000px] bg-background p-4">
105
+ <div className="w-[1100px] bg-background p-6">
33
106
  <Story />
34
107
  </div>
35
108
  ),
@@ -38,7 +111,7 @@ const meta: Meta<typeof ConceptCompare> = {
38
111
  docs: {
39
112
  description: {
40
113
  component:
41
- "One dish or drink across every kitchen that makes it, under whatever name each of them uses. The row is PLACE-LED: it leads with the venue photograph, which is real and licensed, because the reader is choosing between restaurants. A price bar was drawn here first and measured the one value already printed beside it in words. From `lg` the rows are a comparison table; below it they become the production `MenuItem`, with the kitchen's own wording as the name.",
114
+ "One dish across the kitchens that make it and **no surfaces at all**. The card is a contact sheet of venue photographs, one per room, and the three readable things float on it through glass: the identity across the top, each kitchen's line over its own photograph, the foot along the bottom. Read ACROSS, not down: four rooms side by side, each with its own name and price. The photographs are placed as cells of the same grid as the rooms, spanning from the head down, so the head's glass genuinely blurs them and every seam lands on a room boundary. Nothing in it is themed the edge is the only thing that adapts to the page.",
42
115
  },
43
116
  },
44
117
  },
@@ -47,39 +120,48 @@ const meta: Meta<typeof ConceptCompare> = {
47
120
  export default meta;
48
121
  type Story = StoryObj<typeof ConceptCompare>;
49
122
 
50
- export const Default: Story = {
123
+ export const Default: Story = {};
124
+
125
+ /** The same card on a dark page: identical inside, a light hairline and deeper shadow outside. */
126
+ export const DarkPage: Story = {
127
+ decorators: [
128
+ (Story) => (
129
+ <div className="dark w-[1100px] bg-background p-6">
130
+ <Story />
131
+ </div>
132
+ ),
133
+ ],
134
+ };
135
+
136
+ /** Three rooms: one across on the phone, then two. */
137
+ export const ThreeRooms: Story = {
51
138
  args: {
52
- concept: mockConcept,
53
- labels,
54
- itemLabels,
55
- formatPrice,
56
- gradient: mockGradients.pasta,
139
+ concept: { ...concept, title: "Kürtőskalács", rows: rooms.slice(0, 3) },
140
+ lede: "Three kitchens in the city make it",
57
141
  figures: [
58
- { value: "44", label: "kitchens" },
59
- { value: "8—44", label: "lei" },
60
- { value: "168", label: "mentions" },
142
+ { value: "3", label: "kitchens" },
143
+ { value: "14–19", label: "lei across them", accent: true },
144
+ { value: "11", label: "mentions" },
145
+ { value: "3", label: "names" },
61
146
  ],
62
- hrefs,
63
- footnote: "Prices read from each menu as it stands. Portions normalised where the menu states them.",
64
- actions: <Button size="sm">See all 44</Button>,
147
+ footnote:
148
+ "All three kitchens, all three rooms so the spread above is the whole spread. A concept this thin has no tail to open.",
149
+ actions: undefined,
65
150
  },
66
151
  };
67
152
 
68
- /** Below `lg` every row is a `MenuItem`, the alias leading. */
69
- export const Mobile: Story = {
153
+ /** A room with no photograph: the ground with the venue's initial, never a grey box. */
154
+ export const MissingPhoto: Story = {
70
155
  args: {
71
- concept: mockConcept,
72
- labels,
73
- itemLabels,
74
- formatPrice,
75
- gradient: mockGradients.pasta,
76
- figures: [
77
- { value: "44", label: "kitchens" },
78
- { value: "8—44", label: "lei" },
79
- { value: "168", label: "mentions" },
80
- ],
81
- hrefs,
156
+ concept: {
157
+ ...concept,
158
+ rows: rooms.map((r, i) => (i === 2 ? { ...r, place_thumbnail_url: undefined } : r)),
159
+ },
82
160
  },
161
+ };
162
+
163
+ /** 2×2 on the phone — the reading turns from across to around, but it is still four rooms in one view. */
164
+ export const Mobile: Story = {
83
165
  decorators: [
84
166
  (Story) => (
85
167
  <div className="w-[390px] bg-background p-3">
@@ -88,17 +170,3 @@ export const Mobile: Story = {
88
170
  ),
89
171
  ],
90
172
  };
91
-
92
- /** With the concept's own photograph — the one item surface that shows one. */
93
- export const WithPhoto: Story = {
94
- args: {
95
- concept: mockConcept,
96
- labels,
97
- itemLabels,
98
- formatPrice,
99
- gradient: mockGradients.pasta,
100
- imageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=200&h=200&fit=crop",
101
- figures: [{ value: "44", label: "kitchens" }],
102
- hrefs,
103
- },
104
- };