@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,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ConceptConvergence } from "./concept-convergence";
3
+
4
+ function photo(hue: number) {
5
+ return (
6
+ "data:image/svg+xml;utf8," +
7
+ encodeURIComponent(
8
+ `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="320" height="400" fill="url(#g)"/><circle cx="90" cy="120" r="60" fill="hsl(${hue} 40% 80%)" opacity=".6"/></svg>`
9
+ )
10
+ );
11
+ }
12
+
13
+ const meta: Meta<typeof ConceptConvergence> = {
14
+ title: "Menu/ConceptConvergence",
15
+ component: ConceptConvergence,
16
+ tags: ["autodocs"],
17
+ decorators: [
18
+ (Story) => (
19
+ <div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ args: {
25
+ aliases: [
26
+ { label: "Papanași", price: "18 lei" },
27
+ { label: "Papanasi cu smantana si dulceata", price: "24 lei" },
28
+ { label: "Papanași de casă", price: "28 lei" },
29
+ { label: "Papanasi (2 buc)", price: "32 lei" },
30
+ { label: "Papanași cu fineți", price: "44 lei" },
31
+ ],
32
+ concept: { title: "Papanași", photoUrl: photo(30), badge: "32 names" },
33
+ scale: ["8 lei", "45 kitchens", "44 lei"],
34
+ },
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Many names, one dish, drawn on the explainer's dark stage. The kitchens' own spellings of a dish, each with its price, converge on the concept they were matched into — the proof the concepts explainer opens with. Chips are white on ink; the concept card is a room's real photograph on the shared `PhotoGround`.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof ConceptConvergence>;
47
+
48
+ export const Default: Story = {};
49
+
50
+ /** No photograph for the lead room: the ground with the initial. */
51
+ export const NoPhoto: Story = {
52
+ args: { concept: { title: "Ciorbă de burtă", badge: "12 names" } },
53
+ };
@@ -0,0 +1,90 @@
1
+ import { cn } from "../lib/utils";
2
+ import { Badge } from "./badge";
3
+ import { Convergence } from "./convergence";
4
+ import { PhotoGround } from "./photo-ground";
5
+
6
+ /**
7
+ * ConceptConvergence — many names, one dish, drawn on a dark stage.
8
+ *
9
+ * The explainer's proof: the kitchens' OWN spellings of a dish, each with its
10
+ * price, converging on the one concept they were matched into. Five visibly
11
+ * different strings under one photograph with one price column say what a
12
+ * paragraph about "matching" cannot.
13
+ *
14
+ * Drawn for the explainer's dark stage, and only there: the chips are white
15
+ * on ink, not a family's hue — the tint was doing no work once the stage
16
+ * went dark, and the spellings are the point, not the kitchens' cuisines.
17
+ * The concept card is the venue's real photograph, never the item's (which
18
+ * is generated), on the same ground as every photographed card.
19
+ */
20
+
21
+ export type ConceptConvergenceAlias = {
22
+ /** The kitchen's spelling, verbatim. */
23
+ label: string;
24
+ /** Pre-formatted, e.g. "32 lei". */
25
+ price: string;
26
+ };
27
+
28
+ export type ConceptConvergenceProps = {
29
+ aliases: ConceptConvergenceAlias[];
30
+ concept: {
31
+ title: string;
32
+ /** A room's photograph — venue photography only. */
33
+ photoUrl?: string;
34
+ /** "32 names" — the badge on the card. */
35
+ badge: string;
36
+ };
37
+ /**
38
+ * Low, middle and high of the ruler under the diagram, e.g.
39
+ * `["8 lei", "45 kitchens", "44 lei"]`. All three, or none.
40
+ */
41
+ scale?: [string, string, string];
42
+ className?: string;
43
+ };
44
+
45
+ export function ConceptConvergence({ aliases, concept, scale, className }: ConceptConvergenceProps) {
46
+ return (
47
+ <div className={cn("flex flex-col gap-3 text-white", className)}>
48
+ <Convergence
49
+ linesClassName="text-white/30"
50
+ sources={aliases.map((alias, index) => (
51
+ <span
52
+ key={index}
53
+ className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-white/10 px-2.5 text-[11.5px] text-white"
54
+ >
55
+ <span className="min-w-0 truncate">{alias.label}</span>
56
+ <span className="shrink-0 tabular-nums text-white/60">{alias.price}</span>
57
+ </span>
58
+ ))}
59
+ target={
60
+ <span className="relative isolate block aspect-[4/5] w-[112px] overflow-hidden rounded-[16px] shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
61
+ <PhotoGround
62
+ className="-z-10"
63
+ mode="stack"
64
+ photos={[concept.photoUrl]}
65
+ initials={concept.title.charAt(0).toLocaleUpperCase()}
66
+ overlay
67
+ loading="eager"
68
+ />
69
+ <Badge variant="overlay" className="absolute left-2 top-2">
70
+ {concept.badge}
71
+ </Badge>
72
+ <span className="absolute inset-x-2 bottom-2 block text-balance font-serif text-[17px] font-semibold leading-tight tracking-[-0.02em] [text-shadow:0_1px_8px_oklch(0_0_0/0.5)]">
73
+ {concept.title}
74
+ </span>
75
+ </span>
76
+ }
77
+ />
78
+
79
+ {scale && (
80
+ <div className="flex items-center gap-2 text-[11px] tabular-nums text-white/60">
81
+ <span>{scale[0]}</span>
82
+ <span aria-hidden className="h-px flex-1 bg-white/20" />
83
+ <span className="font-semibold text-white">{scale[1]}</span>
84
+ <span aria-hidden className="h-px flex-1 bg-white/20" />
85
+ <span>{scale[2]}</span>
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MessageSquareQuote } from "lucide-react";
3
+ import { Convergence } from "./convergence";
4
+
5
+ const meta: Meta<typeof Convergence> = {
6
+ title: "Menu/Convergence",
7
+ component: Convergence,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[360px] bg-background p-4">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "Many things on the left becoming one thing on the right. The DS draws the shape — the fanned stack, the hairlines curving into one point — and the caller draws the nodes. `ConceptConvergence` is the dish version; this story is the reviews version, redacted lines converging on a mention count.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof Convergence>;
28
+
29
+ export const Reviews: Story = {
30
+ render: () => (
31
+ <Convergence
32
+ linesClassName="text-white/30"
33
+ sources={Array.from({ length: 6 }, (_, index) => (
34
+ <span key={index} className="flex items-center gap-2" aria-hidden>
35
+ <MessageSquareQuote className="h-4 w-4 text-white/40" />
36
+ <span className="h-2 rounded-full bg-white/20" style={{ width: 48 + ((index * 37) % 60) }} />
37
+ </span>
38
+ ))}
39
+ target={
40
+ <span className="flex flex-col items-center rounded-[18px] bg-white/10 px-5 py-4 text-white shadow-[0_8px_24px_oklch(0_0_0/0.35)]">
41
+ <span className="text-[40px] font-extrabold leading-none tabular-nums">56</span>
42
+ <span className="mt-1 text-[11px] text-white/70">mentions</span>
43
+ </span>
44
+ }
45
+ />
46
+ ),
47
+ decorators: [
48
+ (Story) => (
49
+ <div className="w-[360px] bg-[oklch(0.21_0.012_55)] p-4">
50
+ <Story />
51
+ </div>
52
+ ),
53
+ ],
54
+ };
@@ -0,0 +1,177 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { motion, useReducedMotion, type Variants } from "framer-motion";
5
+ import { cn } from "../lib/utils";
6
+ import { duration, ease, springSnappy, stagger } from "../lib/motion";
7
+
8
+ /**
9
+ * Convergence — many things on the left becoming one thing on the right.
10
+ *
11
+ * The diagram the explainers are built on: several spellings of a dish
12
+ * converging on one concept; a column of reviews converging on one mention
13
+ * count. The DS draws the SHAPE — a stack of sources, hairlines curving into
14
+ * a single point, the target — and the caller draws the nodes, because what a
15
+ * source is (a chip, a redacted line, a thumbnail) is the caller's data.
16
+ *
17
+ * ## The motion tells the story
18
+ *
19
+ * Three beats on mount, overlapping, under 700ms: the many arrive (each
20
+ * source slides in from the left, staggered), the lines draw toward the one
21
+ * (each curve starts once its source is about half visible, so cause reads
22
+ * before effect), and the one lands (the target scales in on a snappy spring
23
+ * while the last lines are still in flight — it catches them rather than
24
+ * waiting for a clean hand-off). Self-contained: the sheet this sits in
25
+ * animates by CSS `data-state`, which nothing here could await, and the
26
+ * `delayChildren` already puts the content a beat behind the surface.
27
+ *
28
+ * `useReducedMotion` owns the fallback — the stylesheet's global rule only
29
+ * shortens CSS transitions and does not reach Framer's own scheduler. Under
30
+ * it the diagram renders settled and fades in once. `animate={false}` is a
31
+ * separate switch for static reuse (a docs frame), independent of the
32
+ * reader's setting.
33
+ *
34
+ * ## Why the lines are an SVG and not borders
35
+ *
36
+ * A curve from each source to one point cannot be a border on anything. The
37
+ * SVG stretches to the sources column's height (`preserveAspectRatio: none`)
38
+ * and keeps its hairlines at one pixel through `vector-effect`, so the curves
39
+ * follow however tall the stack is without the stroke thickening with it.
40
+ * `pathLength` is normalised by Framer to the path's own space, so the draw
41
+ * is unaffected by the stretch.
42
+ */
43
+
44
+ /** The first line starts once the first source is about half visible. */
45
+ const LINE_LEAD = 0.15;
46
+ /** The target lands before the last line finishes — it catches them. */
47
+ const TARGET_DELAY = 0.38;
48
+
49
+ const sourcesContainer: Variants = {
50
+ hidden: {},
51
+ show: { transition: { delayChildren: 0.06, staggerChildren: stagger.tight } },
52
+ };
53
+
54
+ const sourceItem: Variants = {
55
+ hidden: { opacity: 0, x: -8 },
56
+ show: { opacity: 1, x: 0, transition: { duration: duration.quick, ease: ease.enter } },
57
+ };
58
+
59
+ const targetItem: Variants = {
60
+ hidden: { opacity: 0, scale: 0.92 },
61
+ show: { opacity: 1, scale: 1, transition: { delay: TARGET_DELAY, ...springSnappy } },
62
+ };
63
+
64
+ export type ConvergeLinesProps = {
65
+ /** How many sources the lines leave from. */
66
+ count: number;
67
+ /** Draw the curves in, source by source. Off: drawn already. */
68
+ animate?: boolean;
69
+ className?: string;
70
+ };
71
+
72
+ const LINES_WIDTH = 40;
73
+ const LINES_HEIGHT = 100;
74
+
75
+ export function ConvergeLines({ count, animate = true, className }: ConvergeLinesProps) {
76
+ const mid = LINES_HEIGHT / 2;
77
+ return (
78
+ <svg
79
+ aria-hidden
80
+ viewBox={`0 0 ${LINES_WIDTH} ${LINES_HEIGHT}`}
81
+ preserveAspectRatio="none"
82
+ className={cn("h-full w-10 text-border", className)}
83
+ >
84
+ {Array.from({ length: count }, (_, index) => {
85
+ const y = ((index + 0.5) / count) * LINES_HEIGHT;
86
+ const d = `M0 ${y} C ${LINES_WIDTH / 2} ${y}, ${LINES_WIDTH / 2} ${mid}, ${LINES_WIDTH} ${mid}`;
87
+ const stroke = {
88
+ d,
89
+ fill: "none",
90
+ stroke: "currentColor",
91
+ strokeWidth: 1.5,
92
+ strokeLinecap: "round" as const,
93
+ vectorEffect: "non-scaling-stroke" as const,
94
+ };
95
+ return animate ? (
96
+ <motion.path
97
+ key={index}
98
+ {...stroke}
99
+ initial={{ pathLength: 0, opacity: 0 }}
100
+ animate={{ pathLength: 1, opacity: 1 }}
101
+ transition={{
102
+ delay: LINE_LEAD + index * stagger.tight,
103
+ duration: duration.standard,
104
+ ease: ease.standard,
105
+ }}
106
+ />
107
+ ) : (
108
+ <path key={index} {...stroke} />
109
+ );
110
+ })}
111
+ </svg>
112
+ );
113
+ }
114
+
115
+ export type ConvergenceProps = {
116
+ /** The many. One node each; the stack fans slightly so it reads as a spread, not a list. */
117
+ sources: ReactNode[];
118
+ /** The one. */
119
+ target: ReactNode;
120
+ /** The entrance choreography. Off for static reuse; the reader's reduced-motion setting is honoured either way. */
121
+ animate?: boolean;
122
+ /** The curves' colour, e.g. `"text-white/30"` on a dark stage. Defaults to the border. */
123
+ linesClassName?: string;
124
+ className?: string;
125
+ };
126
+
127
+ export function Convergence({
128
+ sources,
129
+ target,
130
+ animate = true,
131
+ linesClassName,
132
+ className,
133
+ }: ConvergenceProps) {
134
+ const reduce = useReducedMotion();
135
+ const run = animate && !reduce;
136
+ const fadeOnly = animate && reduce;
137
+
138
+ return (
139
+ <motion.div
140
+ className={cn("grid grid-cols-[minmax(0,1fr)_auto_auto] items-center", className)}
141
+ initial={fadeOnly ? { opacity: 0 } : false}
142
+ animate={fadeOnly ? { opacity: 1 } : undefined}
143
+ transition={{ duration: duration.quick, ease: ease.standard }}
144
+ >
145
+ <motion.div
146
+ className={cn(
147
+ "flex min-w-0 flex-col items-start justify-center gap-2 py-1",
148
+ /* The fan: the middle sources sit a little further right than the
149
+ ends, so five chips read as one hand of cards, not as a menu. */
150
+ "[&>*:nth-child(2)]:translate-x-1.5 [&>*:nth-child(3)]:translate-x-3 [&>*:nth-child(4)]:translate-x-1.5"
151
+ )}
152
+ variants={run ? sourcesContainer : undefined}
153
+ initial={run ? "hidden" : false}
154
+ animate={run ? "show" : undefined}
155
+ >
156
+ {sources.map((node, index) => (
157
+ <motion.div key={index} className="flex max-w-full" variants={run ? sourceItem : undefined}>
158
+ {node}
159
+ </motion.div>
160
+ ))}
161
+ </motion.div>
162
+
163
+ <div className="self-stretch">
164
+ <ConvergeLines count={sources.length} animate={run} className={linesClassName} />
165
+ </div>
166
+
167
+ <motion.div
168
+ className="flex items-center"
169
+ variants={run ? targetItem : undefined}
170
+ initial={run ? "hidden" : false}
171
+ animate={run ? "show" : undefined}
172
+ >
173
+ {target}
174
+ </motion.div>
175
+ </motion.div>
176
+ );
177
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ExplainerSteps } from "./explainer-steps";
3
+
4
+ const meta: Meta<typeof ExplainerSteps> = {
5
+ title: "Menu/ExplainerSteps",
6
+ component: ExplainerSteps,
7
+ tags: ["autodocs"],
8
+ decorators: [
9
+ (Story) => (
10
+ <div className="w-[360px] bg-background p-4">
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ args: {
16
+ steps: [
17
+ { text: "We read 32 spellings of papanași and matched them to one dish.", figure: "32", unit: "names" },
18
+ { text: "Prices compare like for like: every one is a portion of the same dish.", figure: "8–44", unit: "lei" },
19
+ { text: "Open any dish and see what every other kitchen charges for it.", figure: "45", unit: "kitchens" },
20
+ ],
21
+ },
22
+ parameters: {
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "How something works, as numbered rules: a numeral in a ring, one line, and the live figure that proves it with its unit. A thread joins the numerals; the last is filled, because the rules end where the reader is asked to act. A step without a figure shows none.",
27
+ },
28
+ },
29
+ },
30
+ };
31
+
32
+ export default meta;
33
+ type Story = StoryObj<typeof ExplainerSteps>;
34
+
35
+ export const Default: Story = {};
36
+
37
+ /** The third rule has no number, so it shows none. */
38
+ export const UnevenFigures: Story = {
39
+ args: {
40
+ steps: [
41
+ { text: "The reviews of every place in Cluj-Napoca, read in full.", figure: "791", unit: "places" },
42
+ { text: "A mention is one review naming the item.", figure: "68", unit: "here" },
43
+ { text: "The most-named lists are ranked on it, so the top is what the city keeps talking about." },
44
+ ],
45
+ },
46
+ };
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * ExplainerSteps — how something works, as numbered rules a reader can scan.
6
+ *
7
+ * Each step is a numeral in a ring, one line of prose, and the live figure
8
+ * that proves the line, right-aligned with its unit. A thread runs from one
9
+ * numeral to the next so the three read as a sequence, and the last numeral
10
+ * is filled: the rules end where the reader is asked to do something.
11
+ *
12
+ * A step without a figure shows none — an invented number in the column is
13
+ * worse than an empty one. But a list where no step has a figure should be
14
+ * prose, not steps.
15
+ *
16
+ * Server-safe. Data in, not children: a server component composes these from
17
+ * translated strings and numbers, and an array is easier to number than a
18
+ * child list is to clone.
19
+ */
20
+
21
+ export type ExplainerStepItem = {
22
+ /** One line. Two at most on a phone; a third is a paragraph and belongs elsewhere. */
23
+ text: ReactNode;
24
+ /** The live figure, pre-formatted. */
25
+ figure?: string;
26
+ /** A unit or caption beside the figure, e.g. "names". */
27
+ unit?: string;
28
+ };
29
+
30
+ export type ExplainerStepsProps = {
31
+ steps: ExplainerStepItem[];
32
+ className?: string;
33
+ };
34
+
35
+ export function ExplainerSteps({ steps, className }: ExplainerStepsProps) {
36
+ return (
37
+ <ol className={cn("flex flex-col", className)}>
38
+ {steps.map((step, index) => {
39
+ const isLast = index === steps.length - 1;
40
+ return (
41
+ <li key={index} className="relative flex gap-3.5 pb-5 last:pb-0">
42
+ {/* The thread, from this numeral to the next. */}
43
+ {!isLast && (
44
+ <span
45
+ aria-hidden
46
+ className="absolute bottom-0 left-[11px] top-7 w-px bg-border"
47
+ />
48
+ )}
49
+ <span
50
+ aria-hidden
51
+ className={cn(
52
+ "relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
53
+ isLast
54
+ ? "bg-primary text-primary-foreground"
55
+ : "border border-border bg-background text-muted-foreground"
56
+ )}
57
+ >
58
+ {index + 1}
59
+ </span>
60
+ <p className="min-w-0 flex-1 pt-0.5 text-[14px] leading-[1.45] text-foreground">{step.text}</p>
61
+ {step.figure && (
62
+ <span className="shrink-0 pt-0.5 text-right text-[15px] font-bold leading-[1.45] tabular-nums text-foreground">
63
+ {step.figure}
64
+ {step.unit && (
65
+ <span className="ml-1 text-[11px] font-normal text-muted-foreground">{step.unit}</span>
66
+ )}
67
+ </span>
68
+ )}
69
+ </li>
70
+ );
71
+ })}
72
+ </ol>
73
+ );
74
+ }
@@ -0,0 +1,129 @@
1
+ import type { CSSProperties } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * GlassGround — a frosted, inked caption rising out of one edge of a
6
+ * photograph.
7
+ *
8
+ * Layers over a `PhotoGround`: two masked `backdrop-blur`s (the frost) and
9
+ * an ink gradient (the contrast), running in one direction — absent where the
10
+ * photograph is the subject, solid where the type is. `MenuFamilyBlock` drew
11
+ * this first; this is the same idea with the stops as data, so a card can say
12
+ * "clear for 128px, then glass" without a sixth copy of the gradient strings.
13
+ *
14
+ * ## Why two blurs, and why the ramps are eased
15
+ *
16
+ * One blur under a linear mask has an edge: the eye reads the line where
17
+ * sharp becomes soft, however gently the mask fades it in. Two blurs — a
18
+ * light one that starts early and a heavier one that starts late — make the
19
+ * softening itself a gradient (the "progressive blur" of a phone's lock
20
+ * screen), and every ramp here is an ease-in through five stops rather than
21
+ * a straight line, because a straight ramp has a visible crease at each end
22
+ * where its slope changes.
23
+ *
24
+ * ## Why the stops are custom properties
25
+ *
26
+ * Tailwind emits only the classes it can read verbatim, so the gradients
27
+ * cannot be assembled from props into a class. The classes below are literal
28
+ * and read `--glass-*` variables; the component writes those inline, having
29
+ * interpolated the quarter stops in JS (`clear` and `full` must share a
30
+ * unit). Same mechanism as `HueChip`'s hue. The ink's colour is the
31
+ * package's one scrim literal, `oklch(0.16 0.012 55)`, never `var(--scrim)`.
32
+ *
33
+ * ## The ramp ends where the type begins
34
+ *
35
+ * `clear` is where the frost starts, `full` where it and the ink are solid.
36
+ * Set `full` to the exact seam the content starts at — a ramp that runs past
37
+ * the seam puts the first row of type on half-frost.
38
+ */
39
+
40
+ export type GlassStops = {
41
+ /** Which way the ink runs — toward the type. */
42
+ direction: "to bottom" | "to right";
43
+ /** Where the frost begins, e.g. `"0px"` or `"24%"`. */
44
+ clear: string;
45
+ /** Where frost and ink are solid — the seam the content starts at. Same unit as `clear`. */
46
+ full: string;
47
+ };
48
+
49
+ export type GlassGroundProps = {
50
+ stops: GlassStops;
51
+ /** Stops from `lg`. Omit to keep the same ramp at every width. */
52
+ lgStops?: GlassStops;
53
+ /**
54
+ * How dark the ink gets at the far edge, 0–1. `0.96` is a caption the
55
+ * photograph is only a texture under; `0.4` is a photograph the caption is
56
+ * drawn on — the type is lifted, the room stays a room.
57
+ */
58
+ peak?: number;
59
+ /**
60
+ * How much the frost softens the photograph where it is solid. `xl` turns
61
+ * a room into colour; `sm` keeps it a room and only takes the edge off the
62
+ * detail behind the type.
63
+ */
64
+ blur?: "sm" | "md" | "lg" | "xl";
65
+ className?: string;
66
+ };
67
+
68
+ /* Ease-in through the ramp: the fraction of the way from `clear` to `full`
69
+ at which each stop sits, and the share of the peak it carries. */
70
+ const STOPS = [0, 0.25, 0.5, 0.75, 1] as const;
71
+ const EASE = [0, 0.06, 0.25, 0.56, 1] as const;
72
+
73
+ /** The light blur starts at once and is solid halfway; the heavy one starts halfway. */
74
+ const FROST_SOFT =
75
+ "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s0),rgba(0,0,0,0.25)_var(--glass-s1),black_var(--glass-s2))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s0),rgba(0,0,0,0.25)_var(--glass-lg-s1),black_var(--glass-lg-s2))]";
76
+ const FROST_HEAVY =
77
+ "[mask-image:linear-gradient(var(--glass-dir),transparent_var(--glass-s1),rgba(0,0,0,0.25)_var(--glass-s2),rgba(0,0,0,0.6)_var(--glass-s3),black_var(--glass-s4))] lg:[mask-image:linear-gradient(var(--glass-lg-dir),transparent_var(--glass-lg-s1),rgba(0,0,0,0.25)_var(--glass-lg-s2),rgba(0,0,0,0.6)_var(--glass-lg-s3),black_var(--glass-lg-s4))]";
78
+
79
+ const INK =
80
+ "bg-[linear-gradient(var(--glass-dir),oklch(0.16_0.012_55_/_0)_var(--glass-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)] lg:bg-[linear-gradient(var(--glass-lg-dir),oklch(0.16_0.012_55_/_0)_var(--glass-lg-s0),oklch(0.16_0.012_55_/_var(--glass-a1))_var(--glass-lg-s1),oklch(0.16_0.012_55_/_var(--glass-a2))_var(--glass-lg-s2),oklch(0.16_0.012_55_/_var(--glass-a3))_var(--glass-lg-s3),oklch(0.16_0.012_55_/_var(--glass-a4))_var(--glass-lg-s4),oklch(0.16_0.012_55_/_var(--glass-a-peak))_100%)]";
81
+
82
+ /** Literal, for the scanner: the light and the heavy blur per strength. */
83
+ const BLUR = {
84
+ sm: ["backdrop-blur-[2px]", "backdrop-blur-sm"],
85
+ md: ["backdrop-blur-sm", "backdrop-blur-md"],
86
+ lg: ["backdrop-blur-md", "backdrop-blur-lg"],
87
+ xl: ["backdrop-blur-lg", "backdrop-blur-xl"],
88
+ } as const;
89
+
90
+ /** "72px" → [72, "px"]; "24%" → [24, "%"]. */
91
+ function measure(value: string): [number, string] {
92
+ const match = value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i);
93
+ if (!match) throw new Error(`GlassGround: cannot read stop "${value}"`);
94
+ return [Number(match[1]), match[2] || "px"];
95
+ }
96
+
97
+ /** The five ramp positions between `clear` and `full`, as CSS lengths. */
98
+ function positions(clear: string, full: string): string[] {
99
+ const [from, unit] = measure(clear);
100
+ const [to] = measure(full);
101
+ return STOPS.map((fraction) => `${from + (to - from) * fraction}${unit}`);
102
+ }
103
+
104
+ export function GlassGround({
105
+ stops,
106
+ lgStops = stops,
107
+ peak = 0.96,
108
+ blur = "xl",
109
+ className,
110
+ }: GlassGroundProps) {
111
+ const base = positions(stops.clear, stops.full);
112
+ const lg = positions(lgStops.clear, lgStops.full);
113
+ const style = {
114
+ "--glass-dir": stops.direction,
115
+ "--glass-lg-dir": lgStops.direction,
116
+ ...Object.fromEntries(base.map((position, index) => [`--glass-s${index}`, position])),
117
+ ...Object.fromEntries(lg.map((position, index) => [`--glass-lg-s${index}`, position])),
118
+ ...Object.fromEntries(EASE.slice(1).map((share, index) => [`--glass-a${index + 1}`, String(peak * 0.8 * share)])),
119
+ "--glass-a-peak": String(peak),
120
+ } as CSSProperties;
121
+
122
+ return (
123
+ <span aria-hidden className={cn("pointer-events-none absolute inset-0 block", className)} style={style}>
124
+ <span className={cn("absolute inset-0", BLUR[blur][0], FROST_SOFT)} />
125
+ <span className={cn("absolute inset-0", BLUR[blur][1], FROST_HEAVY)} />
126
+ <span className={cn("absolute inset-0", INK)} />
127
+ </span>
128
+ );
129
+ }
@@ -30,7 +30,17 @@ export function HubSurface({ flush, className, children }: HubSurfaceProps) {
30
30
  className={cn(
31
31
  "rounded-[18px] border border-border bg-card shadow-none",
32
32
  flush ? "overflow-hidden" : "px-3.5 py-4",
33
- "lg:overflow-visible lg:rounded-none lg:bg-transparent lg:p-0 lg:shadow-none",
33
+ /*
34
+ * `lg:border-0` is load-bearing and was missing.
35
+ *
36
+ * Above `lg` this surface stops being a card — the background, the
37
+ * radius, the padding and the shadow are all dropped so the section
38
+ * sits on the page. The border was not, so every section on every
39
+ * desktop page kept a 1px rectangle around content that no longer had
40
+ * a card behind it. A caller that genuinely wants an edge at desktop
41
+ * adds `lg:border` in `className`, which lands after this in `cn`.
42
+ */
43
+ "lg:overflow-visible lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0 lg:shadow-none",
34
44
  className
35
45
  )}
36
46
  >