@bearmenu/ui 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/chunk-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,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
+ }
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { HubSurface } from "./hub-surface";
3
+ import { SectionHeader } from "./section-header";
4
+
5
+ const meta: Meta<typeof HubSurface> = {
6
+ title: "Menu/HubSurface",
7
+ component: HubSurface,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[390px] bg-[var(--surface-2,oklch(0.97_0.004_60))] p-3">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The surface a hub section sits on below `lg`. On a phone a hub is an app screen, not a page: each section is a white card on the tinted ground, 18px radius, one shadow, no border. From `lg` up the card dissolves entirely and sections sit flat the way the desktop design draws them — one element, two looks, identical markup inside. Hairlines live inside a card, never across the page.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof HubSurface>;
28
+
29
+ export const Default: Story = {
30
+ render: () => (
31
+ <HubSurface>
32
+ <SectionHeader title="Open right now" />
33
+ <p className="text-sm text-muted-foreground">
34
+ Section content, inset by the surface.
35
+ </p>
36
+ </HubSurface>
37
+ ),
38
+ };
39
+
40
+ /** `flush` drops the inset so a rail or a photograph can bleed to the edge. */
41
+ export const Flush: Story = {
42
+ render: () => (
43
+ <HubSurface flush>
44
+ <div className="px-3.5 pt-4">
45
+ <SectionHeader title="Browse by cuisine" />
46
+ </div>
47
+ <div className="flex gap-3 overflow-x-auto px-3.5 pb-4">
48
+ {[0, 1, 2].map((card) => (
49
+ <div key={card} className="h-24 w-40 shrink-0 rounded-xl bg-muted" />
50
+ ))}
51
+ </div>
52
+ </HubSurface>
53
+ ),
54
+ };
55
+
56
+ /** Stacked, at the 12px rhythm the hub uses between sections. */
57
+ export const Stacked: Story = {
58
+ render: () => (
59
+ <div className="flex flex-col gap-3">
60
+ {["Open right now", "One kitchen worth knowing", "Most named"].map((title) => (
61
+ <HubSurface key={title}>
62
+ <SectionHeader title={title} />
63
+ <div className="h-12 rounded-lg bg-muted" />
64
+ </HubSurface>
65
+ ))}
66
+ </div>
67
+ ),
68
+ };
@@ -0,0 +1,50 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * HubSurface — the surface a hub section sits on BELOW `lg`.
5
+ *
6
+ * On a phone a hub is an app screen, not a page: each section is a white card
7
+ * on the tinted ground, 18px radius, one card shadow and no border, with 14px
8
+ * of inset. Hairlines live inside a card, never across the page. From `lg` up
9
+ * the card dissolves — no fill, no radius, no inset — and the sections sit flat
10
+ * the way the desktop design draws them. One element, two looks; the section
11
+ * markup inside is identical at every width.
12
+ *
13
+ * `flush` drops the inset so a section whose content bleeds to the card edge —
14
+ * a snap rail, a full-width photograph — can pad its own header and let the
15
+ * rest run.
16
+ *
17
+ * Generalised out of the events hub's `EventsHubCard`, which had the same job
18
+ * and lived in the app. Nothing here is menu- or event-shaped.
19
+ */
20
+
21
+ export type HubSurfaceProps = {
22
+ flush?: boolean;
23
+ className?: string;
24
+ children: React.ReactNode;
25
+ };
26
+
27
+ export function HubSurface({ flush, className, children }: HubSurfaceProps) {
28
+ return (
29
+ <section
30
+ className={cn(
31
+ "rounded-[18px] border border-border bg-card shadow-none",
32
+ flush ? "overflow-hidden" : "px-3.5 py-4",
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",
44
+ className
45
+ )}
46
+ >
47
+ {children}
48
+ </section>
49
+ );
50
+ }
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Wallet } from "lucide-react";
3
+ import { HueChip } from "./hue-chip";
4
+
5
+ const meta: Meta<typeof HueChip> = {
6
+ title: "Menu/HueChip",
7
+ component: HueChip,
8
+ tags: ["autodocs"],
9
+ args: { children: "Sweet Pastry", hue: "oklch(0.62 0.14 82)" },
10
+ parameters: {
11
+ docs: {
12
+ description: {
13
+ component:
14
+ "A pill wearing a category's colour as a pale wash. It exists because the wash has one non-obvious rule — the mix must happen `in oklab`, never `in oklch` — and that rule has to live in one place. oklch is polar, so mixing a hue into an achromatic `--card` interpolates the hue ANGLE toward 0: honey 82° arrives at 16° (pink), a bar's 205° blue at 41° (orange). The hue is runtime data from ~40 cuisines, so it is an inline custom property rather than a Tailwind class — Tailwind can only emit what it sees at build time.",
15
+ },
16
+ },
17
+ },
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof HueChip>;
22
+
23
+ export const Default: Story = {};
24
+
25
+ /**
26
+ * The bug this component was extracted to prevent. Every hue survives the mix
27
+ * with its own identity — the cool families especially, which under `in oklch`
28
+ * came out orange and mustard.
29
+ */
30
+ export const EveryFamily: Story = {
31
+ render: () => (
32
+ <div className="flex flex-wrap gap-1.5">
33
+ {[
34
+ ["Breakfast & Brunch", "oklch(0.62 0.14 82)"],
35
+ ["Romanian", "oklch(0.48 0.16 25)"],
36
+ ["Italian", "oklch(0.56 0.15 38)"],
37
+ ["Vegetarian", "oklch(0.52 0.14 148)"],
38
+ ["Bars", "oklch(0.47 0.10 205)"],
39
+ ["Clubs", "oklch(0.42 0.11 300)"],
40
+ ].map(([label, hue]) => (
41
+ <HueChip key={label} hue={hue}>
42
+ {label}
43
+ </HueChip>
44
+ ))}
45
+ </div>
46
+ ),
47
+ };
48
+
49
+ /** No hue — `--muted`, the right neutral for a facet with no family behind it. */
50
+ export const Untinted: Story = {
51
+ args: { hue: undefined },
52
+ };
53
+
54
+ /** A price band: an icon, a bare figure, and an accessible name for it. */
55
+ export const WithIcon: Story = {
56
+ args: {
57
+ hue: undefined,
58
+ className: "font-bold tabular-nums",
59
+ "aria-label": "kitchen price band: 22–86 lei",
60
+ icon: <Wallet className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />,
61
+ children: "22–86 lei",
62
+ },
63
+ };
64
+
65
+ /** In a list, which is where most of these live. */
66
+ export const InAList: Story = {
67
+ render: () => (
68
+ <ul className="flex flex-wrap items-center gap-1.5">
69
+ {["Sweet Pastry", "Salad", "Cake"].map((label) => (
70
+ <HueChip key={label} as="li" hue="oklch(0.62 0.14 82)">
71
+ {label}
72
+ </HueChip>
73
+ ))}
74
+ </ul>
75
+ ),
76
+ };
@@ -0,0 +1,62 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { HueChip, hueTint } from "./hue-chip";
4
+
5
+ /**
6
+ * The whole reason this component exists is one character of CSS, so that is
7
+ * what is pinned. `color-mix(in oklch, …)` interpolates the hue ANGLE, and the
8
+ * paper it mixes into is achromatic (`--card` is `oklch(1 0 0)`, hue 0), so
9
+ * every family colour was dragged a proportional fraction of the way to red:
10
+ * honey 82° arrived at 16°, pink. It is invisible in a diff and obvious on
11
+ * screen, which is exactly the kind of thing a test should hold.
12
+ */
13
+ describe("hueTint", () => {
14
+ it("mixes in the rectangular space, never the polar one", () => {
15
+ const tint = hueTint("oklch(0.62 0.14 82)");
16
+ expect(tint).toContain("in oklab");
17
+ expect(tint).not.toContain("in oklch,");
18
+ });
19
+
20
+ it("mixes into the surface the chip sits on", () => {
21
+ expect(hueTint("oklch(0.62 0.14 82)")).toContain("var(--card)");
22
+ expect(hueTint("oklch(0.62 0.14 82)", "chip", "var(--background)")).toContain(
23
+ "var(--background)"
24
+ );
25
+ });
26
+
27
+ it("gives a subtle chip less of the hue than a plain one", () => {
28
+ expect(hueTint("oklch(0.62 0.14 82)", "chip")).toContain("20%");
29
+ expect(hueTint("oklch(0.62 0.14 82)", "subtle")).toContain("12%");
30
+ });
31
+ });
32
+
33
+ describe("HueChip", () => {
34
+ it("wears the tint when it has a hue", () => {
35
+ render(<HueChip hue="oklch(0.62 0.14 82)">Sweet Pastry</HueChip>);
36
+ const chip = screen.getByText("Sweet Pastry");
37
+ expect(chip.getAttribute("style")).toContain("oklab");
38
+ expect(chip.className).not.toContain("bg-muted");
39
+ });
40
+
41
+ it("falls back to the neutral rather than to a colour", () => {
42
+ // A facet with no family behind it must not borrow another family's colour.
43
+ render(<HueChip>Fast Food</HueChip>);
44
+ const chip = screen.getByText("Fast Food");
45
+ expect(chip.className).toContain("bg-muted");
46
+ expect(chip.getAttribute("style")).toBeNull();
47
+ });
48
+
49
+ it("renders as a list item when asked, for the lists it lives in", () => {
50
+ render(
51
+ <ul>
52
+ <HueChip as="li">Salad</HueChip>
53
+ </ul>
54
+ );
55
+ expect(screen.getByText("Salad").tagName).toBe("LI");
56
+ });
57
+
58
+ it("takes an accessible name for a chip whose text is a bare figure", () => {
59
+ render(<HueChip aria-label="kitchen price band: 22–86 lei">22–86 lei</HueChip>);
60
+ expect(screen.getByLabelText("kitchen price band: 22–86 lei")).toBeInTheDocument();
61
+ });
62
+ });
@@ -0,0 +1,99 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * HueChip — a pill that wears a category's colour as a pale wash.
5
+ *
6
+ * Menu surfaces label things with a family's hue: a cuisine's dish groups, a
7
+ * going-out family's tags, a price band. Every one of them was an inline
8
+ * `style` with a hand-written `color-mix` next to a hand-written pill class
9
+ * list, which is how the two below drifted apart and how the mix space bug
10
+ * survived as long as it did — it had to be found and fixed in each copy.
11
+ *
12
+ * ## The mix space is the whole reason this is a component
13
+ *
14
+ * **`in oklab`, and never `in oklch`.** They describe the same colours, but
15
+ * oklch is POLAR, so `color-mix(in oklch, …)` interpolates the hue ANGLE — and
16
+ * the paper being mixed into is achromatic (`--card` is `oklch(1 0 0)`, hue 0).
17
+ * Mixing 20% of a family colour into that lands at a fifth of the family's hue:
18
+ * honey 82° came out at 16°, which is pink, so a Breakfast & Brunch card wore
19
+ * pink chips under an amber mosaic. Cool families were worse — a bar's 205°
20
+ * blue mixes to 41°, orange; a club's 300° violet to 60°, mustard.
21
+ *
22
+ * oklab is rectangular: mixing toward an achromatic base scales chroma along
23
+ * the SAME hue instead of rotating it, which is what "a pale version of this
24
+ * colour" has to mean.
25
+ *
26
+ * ## Why an inline style and not a Tailwind class
27
+ *
28
+ * The hue is DATA — one of ~40 cuisine values resolved by the app at request
29
+ * time — not one of a fixed set of variants. Tailwind can only emit classes it
30
+ * can see at build time, so a class-based version would mean either a
31
+ * safelisted colour per cuisine (a palette that must be edited to add a
32
+ * cuisine) or an arbitrary-value class built from a runtime string, which
33
+ * Tailwind cannot compile. A CSS custom property set inline is the honest
34
+ * mechanism; this component is the one place that writes it.
35
+ */
36
+
37
+ /** Mix strengths, so callers say what a chip IS rather than picking a number. */
38
+ const STRENGTH = {
39
+ /** A labelled facet: readable at 11.5px, still clearly the family's colour. */
40
+ chip: 20,
41
+ /** A quieter wash, for a chip that sits beside a stronger one. */
42
+ subtle: 12,
43
+ } as const;
44
+
45
+ export type HueChipTone = keyof typeof STRENGTH;
46
+
47
+ /**
48
+ * The tint itself, exported for the surfaces that need the colour rather than
49
+ * the pill — a section ground, a hover state. Same rule: `in oklab`.
50
+ */
51
+ export function hueTint(hue: string, tone: HueChipTone = "chip", base = "var(--card)"): string {
52
+ return `color-mix(in oklab, ${hue} ${STRENGTH[tone]}%, ${base})`;
53
+ }
54
+
55
+ export type HueChipProps = {
56
+ /**
57
+ * A finished CSS colour — the DS owns no category table, so the app resolves
58
+ * it. Without one the chip falls back to `--muted`, which is the right
59
+ * neutral for a facet that has no family behind it.
60
+ */
61
+ hue?: string;
62
+ tone?: HueChipTone;
63
+ /** The paper the wash is mixed into. Must be the surface the chip sits on. */
64
+ base?: string;
65
+ /** Drawn before the label, e.g. a wallet on a price band. */
66
+ icon?: React.ReactNode;
67
+ /** `li` inside a list, which is where most of these live. */
68
+ as?: "span" | "li" | "div";
69
+ children: React.ReactNode;
70
+ className?: string;
71
+ /** For a chip whose visible text is a bare figure, e.g. "22–86 lei". */
72
+ "aria-label"?: string;
73
+ };
74
+
75
+ export function HueChip({
76
+ hue,
77
+ tone = "chip",
78
+ base = "var(--card)",
79
+ icon,
80
+ as: Tag = "span",
81
+ children,
82
+ className,
83
+ "aria-label": ariaLabel,
84
+ }: HueChipProps) {
85
+ return (
86
+ <Tag
87
+ aria-label={ariaLabel}
88
+ className={cn(
89
+ "inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-foreground",
90
+ !hue && "bg-muted",
91
+ className
92
+ )}
93
+ style={hue ? { backgroundColor: hueTint(hue, tone, base) } : undefined}
94
+ >
95
+ {icon}
96
+ {children}
97
+ </Tag>
98
+ );
99
+ }