@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,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
+ }
@@ -0,0 +1,108 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ArrowRight } from "lucide-react";
3
+ import { KitchenDossier } from "./kitchen-dossier";
4
+ import { mockVenues } from "./menu-mocks";
5
+
6
+ function photo(hue: number) {
7
+ return (
8
+ "data:image/svg+xml;utf8," +
9
+ encodeURIComponent(
10
+ `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640"><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="960" height="640" fill="url(#g)"/><circle cx="240" cy="180" r="120" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="520" y="360" width="360" height="220" fill="hsl(${hue} 15% 20%)" opacity=".7"/></svg>`
11
+ )
12
+ );
13
+ }
14
+
15
+ const venue = mockVenues[0];
16
+
17
+ const labels = {
18
+ knownFor: "Known for",
19
+ whatPeopleSay: "What people say",
20
+ theGrade: "The grade",
21
+ currency: "lei",
22
+ mentions: (count: number) => `${count} name it`,
23
+ reviewCount: "427 reviews",
24
+ openUntil: "Open until 22:00",
25
+ part: (index: number, count: number) => `Part ${index + 1} of ${count}`,
26
+ next: "Next",
27
+ dimensions: {
28
+ food: "Food",
29
+ service: "Service",
30
+ value: "Value",
31
+ ambiance: "Ambiance",
32
+ portion: "Portion",
33
+ },
34
+ };
35
+
36
+ const action = (
37
+ <a
38
+ href={venue.href}
39
+ className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-5 text-[13.5px] font-bold text-[oklch(0.21_0.012_55)]"
40
+ >
41
+ Open the menu
42
+ <ArrowRight className="h-4 w-4" aria-hidden />
43
+ </a>
44
+ );
45
+
46
+ const meta: Meta<typeof KitchenDossier> = {
47
+ title: "Menu/KitchenDossier",
48
+ component: KitchenDossier,
49
+ tags: ["autodocs"],
50
+ decorators: [
51
+ (Story) => (
52
+ <div className="w-[390px] bg-background p-4">
53
+ <Story />
54
+ </div>
55
+ ),
56
+ ],
57
+ args: {
58
+ place: { ...venue.place, thumbnail_url: photo(30) },
59
+ href: venue.href,
60
+ meta: "Restaurant · Centru",
61
+ signature: venue.signature,
62
+ quotes: [
63
+ { text: "Complimentary still or sparkling water with every meal, plus free coffee with brunch dishes." },
64
+ { text: "Incredibly attentive and friendly service that creates a truly relaxing experience." },
65
+ ],
66
+ grade: {
67
+ overall: "9.4",
68
+ dimensions: { food: 91, service: 90, value: 91, ambiance: 87, portion: 85 },
69
+ },
70
+ labels,
71
+ formatPrice: (amount: number) => String(amount),
72
+ formatScore: (value: number) => (value / 10).toFixed(1),
73
+ action,
74
+ },
75
+ parameters: {
76
+ docs: {
77
+ description: {
78
+ component:
79
+ "One kitchen, told as a story on its own photograph. The room is clear at the top; a frosted caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
80
+ },
81
+ },
82
+ },
83
+ };
84
+
85
+ export default meta;
86
+ type Story = StoryObj<typeof KitchenDossier>;
87
+
88
+ export const Phone: Story = {};
89
+
90
+ export const Desktop: Story = {
91
+ decorators: [
92
+ (Story) => (
93
+ <div className="w-[1100px] bg-background p-4">
94
+ <Story />
95
+ </div>
96
+ ),
97
+ ],
98
+ };
99
+
100
+ /** One part only: no segments, no tap-to-advance, the panel just is. */
101
+ export const OnePart: Story = {
102
+ args: { quotes: undefined, grade: undefined },
103
+ };
104
+
105
+ /** No photograph: the ground with the initial. */
106
+ export const NoPhoto: Story = {
107
+ args: { place: venue.place },
108
+ };
@@ -0,0 +1,322 @@
1
+ import type { ReactNode } from "react";
2
+ import { MessageSquareQuote, Star } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { GlassGround } from "./glass-ground";
5
+ import type { MenuLinkComponent, MenuLinkProps, MenuVenueSignature } from "./menu-types";
6
+ import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
7
+ import type { CitedText, PlaceGradeDimensions, PlacePreview } from "./place-types";
8
+ import { Separator } from "./separator";
9
+ import { StoryPanel, StoryProvider, StorySegments } from "./story";
10
+ import {
11
+ DIMENSION_ORDER,
12
+ VenueGradeDimensions,
13
+ type VenueGradeDimensionKey,
14
+ } from "./venue-grade-dimensions";
15
+ import { VenueQuotes } from "./venue-quotes";
16
+
17
+ /**
18
+ * KitchenDossier — one kitchen, told as a story on its own photograph.
19
+ *
20
+ * The hub used to say this three times: a spotlight with everything the
21
+ * digest knows, a ranked row with a grade, a card with the signature dish —
22
+ * and the same kitchen led all three. This is the one card: the venue's
23
+ * photograph clear at the top, a frosted caption rising from the foot with
24
+ * the identity (rating, name, type · area · reviews), and under it a PANEL
25
+ * that says the three or four things the reviews established one at a time —
26
+ * the dish it is known for, what people say, the grade — with segments at the
27
+ * top edge showing which is up. The story runs on its own while the card is
28
+ * in view; a tap advances.
29
+ *
30
+ * ## A photograph with a caption, not a dark card
31
+ *
32
+ * `ConceptCompare` is a dark card with a photograph showing through two glass
33
+ * bands. This is the reverse — the room is the subject and the facts are its
34
+ * caption — which is `GlassGround` at its lightest and smallest: anchored to
35
+ * the CAPTION, not the card, so it covers the identity, the panel and the
36
+ * action plus a short lead above them and nothing else; a `sm` blur that
37
+ * only takes the edge off the detail; ink that never passes 0.45; a text
38
+ * shadow under the identity doing the rest. Everything above the caption is
39
+ * the photograph, untouched. The panel is its own glass, so its rule reads
40
+ * as a panel and not a hole. The same layout at every width; the desktop
41
+ * card is wider, not turned on its side.
42
+ *
43
+ * ## Two targets, and everything else is "next"
44
+ *
45
+ * The name and the action open the venue; the dish name in the first part
46
+ * opens the item. A tap anywhere else on the card advances the story.
47
+ */
48
+
49
+ /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
50
+ export type KitchenDossierLinkProps = MenuLinkProps;
51
+
52
+ function DefaultLink({ href, ...props }: KitchenDossierLinkProps) {
53
+ return <a href={href} {...props} />;
54
+ }
55
+
56
+ export type KitchenDossierLabels = {
57
+ /** Kickers over the three parts. */
58
+ knownFor: string;
59
+ whatPeopleSay: string;
60
+ theGrade: string;
61
+ /** Currency beside the price, e.g. "lei". */
62
+ currency: string;
63
+ /** Beside the mention count, e.g. "4 name it". */
64
+ mentions: (count: number) => string;
65
+ /** "427 reviews", composed by the caller. */
66
+ reviewCount?: string;
67
+ /** "Open until 22:00", composed by the caller. Omit when closed or unknown. */
68
+ openUntil?: string;
69
+ /** Accessible name per segment, e.g. "Part 2 of 3". */
70
+ part: (index: number, count: number) => string;
71
+ /** Accessible name of the tap-to-advance. */
72
+ next: string;
73
+ /** Dimension key → translated label. */
74
+ dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
75
+ };
76
+
77
+ export type KitchenDossierProps = {
78
+ place: PlacePreview;
79
+ /** The venue's destination. */
80
+ href: string;
81
+ /** "Restaurant · Centru", composed by the caller. */
82
+ meta?: string;
83
+ /** Part one. */
84
+ signature?: MenuVenueSignature;
85
+ /** Part two — the digest's highlights; two are shown. */
86
+ quotes?: CitedText[];
87
+ /** Part three — the grade, with the overall already formatted. */
88
+ grade?: { overall?: string; dimensions?: PlaceGradeDimensions };
89
+ labels: KitchenDossierLabels;
90
+ formatPrice: (amount: number) => string;
91
+ /** 0–100 → display numeral. */
92
+ formatScore: (value: number) => string;
93
+ /** How long each part shows. */
94
+ partDurationMs?: number;
95
+ /** The foot's action — pass a rendered link or button. */
96
+ action?: ReactNode;
97
+ linkComponent?: MenuLinkComponent;
98
+ className?: string;
99
+ };
100
+
101
+ /**
102
+ * How far above the caption the glass begins — the ramp from clear photograph
103
+ * to frost, ending exactly where the identity starts. Long, so the edge is a
104
+ * gradient and not a line. The glass wrapper's `-top-32` is this same 128px;
105
+ * the two must agree, or the identity sits on half-frost.
106
+ */
107
+ const GLASS_LEAD = "128px";
108
+
109
+ /** How many dimensions the grade part shows: the three strongest. */
110
+ const DIMENSION_LIMIT = 3;
111
+
112
+ /** How many quotes the second part shows. */
113
+ const QUOTE_LIMIT = 2;
114
+
115
+ const initialOf = (name: string) => (name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
116
+
117
+ export function KitchenDossier({
118
+ place,
119
+ href,
120
+ meta,
121
+ signature,
122
+ quotes,
123
+ grade,
124
+ labels,
125
+ formatPrice,
126
+ formatScore,
127
+ partDurationMs,
128
+ action,
129
+ linkComponent: LinkComponent = DefaultLink,
130
+ className,
131
+ }: KitchenDossierProps) {
132
+ const rating = place.rating?.rating;
133
+ const parts: ReactNode[] = [];
134
+
135
+ if (signature) {
136
+ const item = signature.item;
137
+ const firstEntry = item.sizes_and_prices?.[0];
138
+ const mentions = item.mentions ?? item.review_count ?? 0;
139
+ parts.push(
140
+ <Part key="signature" kicker={labels.knownFor}>
141
+ {signature.href ? (
142
+ <LinkComponent
143
+ href={signature.href}
144
+ className="w-max max-w-full text-[16px] font-bold leading-tight tracking-[-0.01em] text-white hover:underline focus-visible:outline-none focus-visible:underline"
145
+ >
146
+ {item.name}
147
+ </LinkComponent>
148
+ ) : (
149
+ <span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
150
+ )}
151
+ {signature.why && (
152
+ <p className="mt-1 line-clamp-2 font-serif text-[13px] italic leading-snug text-white/75">
153
+ {signature.why}
154
+ </p>
155
+ )}
156
+ {(firstEntry || mentions > 0) && (
157
+ <>
158
+ <Separator className="my-2.5 via-white/20" />
159
+ <div className="flex items-end justify-between gap-3">
160
+ <span className="flex items-baseline gap-1.5">
161
+ {firstEntry && (
162
+ <>
163
+ <span className="text-[18px] font-extrabold leading-none tabular-nums text-white">
164
+ {formatPrice(firstEntry.price)}
165
+ <span className="ml-1 text-[12px] font-bold">{labels.currency}</span>
166
+ </span>
167
+ {firstEntry.size > 0 && (
168
+ <span className="text-[11.5px] tabular-nums text-white/60">
169
+ {firstEntry.size}
170
+ {item.kind === "dish" ? "g" : "ml"}
171
+ </span>
172
+ )}
173
+ </>
174
+ )}
175
+ </span>
176
+ {mentions > 0 && (
177
+ <span className="inline-flex items-center gap-1 text-[12px] font-semibold tabular-nums text-white/85">
178
+ <MessageSquareQuote className="h-3.5 w-3.5" aria-hidden />
179
+ {labels.mentions(mentions)}
180
+ </span>
181
+ )}
182
+ </div>
183
+ </>
184
+ )}
185
+ </Part>
186
+ );
187
+ }
188
+
189
+ if (quotes && quotes.length > 0) {
190
+ parts.push(
191
+ <Part key="quotes" kicker={labels.whatPeopleSay}>
192
+ <VenueQuotes quotes={quotes} limit={QUOTE_LIMIT} className="lg:grid-cols-1" />
193
+ </Part>
194
+ );
195
+ }
196
+
197
+ /* The three strongest, ties broken by the canonical order so that food
198
+ beats cleanliness at an equal score — the reader asked about a kitchen. */
199
+ const dimensions = grade?.dimensions;
200
+ const strongest = dimensions
201
+ ? DIMENSION_ORDER.filter((key) => typeof dimensions[key] === "number")
202
+ .sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
203
+ .slice(0, DIMENSION_LIMIT)
204
+ : [];
205
+ if (grade && (grade.overall || strongest.length > 0)) {
206
+ parts.push(
207
+ <Part key="grade" kicker={labels.theGrade}>
208
+ <div className="flex items-end gap-4">
209
+ {grade.overall && (
210
+ <span className="text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums text-[oklch(0.82_0.15_85)]">
211
+ {grade.overall}
212
+ </span>
213
+ )}
214
+ {dimensions && strongest.length > 0 && (
215
+ <VenueGradeDimensions
216
+ className="min-w-0 flex-1"
217
+ dimensions={dimensions}
218
+ labels={labels.dimensions}
219
+ order={strongest}
220
+ formatScore={formatScore}
221
+ />
222
+ )}
223
+ </div>
224
+ </Part>
225
+ );
226
+ }
227
+
228
+ return (
229
+ <article
230
+ className={cn(
231
+ /* `flex flex-col` so the story fills the card's height: in a rail
232
+ the cards are stretched to the tallest, and a story that stopped
233
+ at its own min-height left a band of bare photograph under the
234
+ action on every shorter card. */
235
+ "dark relative isolate flex flex-col overflow-hidden rounded-[18px] border border-border text-foreground shadow-[var(--shadow-card)]",
236
+ PHOTO_GROUND,
237
+ className
238
+ )}
239
+ >
240
+ <PhotoGround className="-z-20" photos={[place.thumbnail_url]} initials={initialOf(place.name)} loading="eager" />
241
+
242
+ <StoryProvider
243
+ count={parts.length}
244
+ durationMs={partDurationMs}
245
+ nextLabel={labels.next}
246
+ className="relative flex min-h-[560px] flex-1 flex-col p-4 lg:min-h-[440px] lg:p-5"
247
+ >
248
+ <StorySegments
249
+ labels={parts.map((_, index) => labels.part(index, parts.length))}
250
+ className="-mt-1"
251
+ />
252
+
253
+ {/* The clear photograph, with the one fact that belongs on it. */}
254
+ <div className="relative min-h-[184px] flex-1">
255
+ {labels.openUntil && (
256
+ <span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-3 text-[11.5px] font-bold text-white backdrop-blur-[2px]">
257
+ <i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
258
+ {labels.openUntil}
259
+ </span>
260
+ )}
261
+ </div>
262
+
263
+ {/* The caption: identity, the story's panel, the action — and the
264
+ glass, anchored to the caption rather than to the card, so it
265
+ covers the caption and the `GLASS_LEAD` above it and nothing more.
266
+ Bled to the card's edges past the story's padding. */}
267
+ <div className="relative flex flex-col gap-3.5">
268
+ <div
269
+ aria-hidden
270
+ className="pointer-events-none absolute -inset-x-4 -bottom-4 -top-32 -z-10 lg:-inset-x-5 lg:-bottom-5"
271
+ >
272
+ <GlassGround
273
+ stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
274
+ peak={0.4}
275
+ blur="sm"
276
+ />
277
+ </div>
278
+ <div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
279
+ <div className="flex items-baseline justify-between gap-4">
280
+ <h3 className="min-w-0 font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
281
+ <LinkComponent href={href} className="focus-visible:outline-none focus-visible:underline">
282
+ {place.name}
283
+ </LinkComponent>
284
+ </h3>
285
+ {rating !== undefined && (
286
+ <span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
287
+ {rating.toFixed(1)}
288
+ <Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
289
+ </span>
290
+ )}
291
+ </div>
292
+ {(meta || labels.reviewCount) && (
293
+ <p className="mt-1 truncate text-[12.5px] text-white/70">
294
+ {[meta, labels.reviewCount].filter(Boolean).join(" · ")}
295
+ </p>
296
+ )}
297
+ </div>
298
+
299
+ {parts.length > 0 && (
300
+ <StoryPanel
301
+ parts={parts}
302
+ className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.35)] p-4 backdrop-blur-md"
303
+ />
304
+ )}
305
+
306
+ {action && <div className="flex [&>*]:w-full lg:[&>*]:w-auto">{action}</div>}
307
+ </div>
308
+ </StoryProvider>
309
+ </article>
310
+ );
311
+ }
312
+
313
+ function Part({ kicker, children }: { kicker: string; children: ReactNode }) {
314
+ return (
315
+ <div className="flex flex-col">
316
+ <span className="mb-1.5 block text-[9.5px] font-bold uppercase tracking-[0.12em] text-white/60">
317
+ {kicker}
318
+ </span>
319
+ {children}
320
+ </div>
321
+ );
322
+ }