@bearmenu/ui 0.8.7 → 0.8.9

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 (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockVenues } from "./menu-mocks";
3
+ import { RankedVenueList } from "./ranked-venue-list";
4
+
5
+ const priceBandLabels = {
6
+ "the-copper-yard": "19—62",
7
+ "salt-and-stone": "38—84",
8
+ "the-quiet-hours": "8—26",
9
+ "green-room-canteen": "22—51",
10
+ };
11
+
12
+ const meta: Meta<typeof RankedVenueList> = {
13
+ title: "Menu/RankedVenueList",
14
+ component: RankedVenueList,
15
+ tags: ["autodocs"],
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[620px] bg-background p-4">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "Venues ordered by how often reviews name their dishes — the answer to \"best Italian in Cluj\". What makes the order defensible is the mention count on every row: it is evidence, not opinion. A caller that cannot supply mentions should use a different surface rather than this one with the column blank, because an unexplained ranking is worse than none. The tab strip re-ranks in place and never navigates.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof RankedVenueList>;
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ venues: mockVenues.slice(0, 3),
39
+ heading: "The kitchens Cluj rates for Italian",
40
+ caption: "by how often reviews name their dishes",
41
+ priceBandLabels,
42
+ },
43
+ };
44
+
45
+ /** With tabs. `activeTab` and `onTabChange` are the caller's state. */
46
+ export const WithTabs: Story = {
47
+ args: {
48
+ venues: mockVenues.slice(0, 3),
49
+ heading: "The kitchens Cluj rates",
50
+ caption: "from review mentions, not opinion",
51
+ tabs: [
52
+ { slug: "italian", label: "Italian" },
53
+ { slug: "romanesc", label: "Romanian" },
54
+ { slug: "japonez", label: "Japanese" },
55
+ { slug: "burgeri", label: "Burgers" },
56
+ ],
57
+ activeTab: "italian",
58
+ onTabChange: () => {},
59
+ priceBandLabels,
60
+ },
61
+ };
62
+
63
+ /** No header at all — the list inside a section that already has one. */
64
+ export const Bare: Story = { args: { venues: mockVenues.slice(0, 3), priceBandLabels } };
65
+
66
+ /** A venue with no signature still ranks; its quoted dish line is simply absent. */
67
+ export const MixedEvidence: Story = {
68
+ args: { venues: mockVenues, heading: "The kitchens Cluj rates", priceBandLabels },
69
+ };
70
+
71
+ export const Mobile: Story = {
72
+ args: { venues: mockVenues.slice(0, 3), heading: "The kitchens Cluj rates" },
73
+ decorators: [
74
+ (Story) => (
75
+ <div className="w-[390px] bg-background p-3">
76
+ <Story />
77
+ </div>
78
+ ),
79
+ ],
80
+ };
@@ -0,0 +1,177 @@
1
+ import { ChevronRight, MessageSquare, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
5
+
6
+ /**
7
+ * RankedVenueList — venues ordered by how often reviews name their dishes.
8
+ *
9
+ * The answer to "best Italian in Cluj", and the reason it is defensible is the
10
+ * mention count sitting on every row: the order is evidence, not opinion. A
11
+ * caller that cannot supply mentions should use a different surface rather than
12
+ * this one with the column blank — an unexplained ranking is worse than none.
13
+ *
14
+ * The tab strip re-ranks in place and never navigates, so `activeTab` and
15
+ * `onTabChange` are controlled by the caller. Omit `tabs` entirely and the
16
+ * header is just a heading.
17
+ *
18
+ * No directive: without `tabs` the list renders on the server, which is the
19
+ * common case on a hub.
20
+ */
21
+
22
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
23
+ export type RankedVenueListLinkProps = MenuLinkProps;
24
+
25
+ function DefaultLink({ href, ...props }: RankedVenueListLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type RankedVenueListTab = {
30
+ slug: string;
31
+ label: string;
32
+ };
33
+
34
+ export type RankedVenueListProps = {
35
+ venues: MenuVenuePreview[];
36
+ /** Section heading, e.g. "The kitchens Cluj rates for Italian". */
37
+ heading?: string;
38
+ /** The basis of the order, e.g. "by how often reviews name their dishes". */
39
+ caption?: string;
40
+ tabs?: RankedVenueListTab[];
41
+ activeTab?: string;
42
+ onTabChange?: (slug: string) => void;
43
+ /** Composed price band per venue, keyed by place slug, e.g. "38—84". */
44
+ priceBandLabels?: Record<string, string>;
45
+ /**
46
+ * The app's own grade per venue slug, already formatted ("8.7"). Shown in
47
+ * place of the star rating when present — the star is the third-party
48
+ * fallback for a venue the reviews have not graded yet.
49
+ */
50
+ gradeLabels?: Record<string, string>;
51
+ linkComponent?: MenuLinkComponent;
52
+ className?: string;
53
+ };
54
+
55
+ export function RankedVenueList({
56
+ venues,
57
+ heading,
58
+ caption,
59
+ tabs,
60
+ activeTab,
61
+ onTabChange,
62
+ priceBandLabels,
63
+ gradeLabels,
64
+ linkComponent: LinkComponent = DefaultLink,
65
+ className,
66
+ }: RankedVenueListProps) {
67
+ return (
68
+ <div className={cn("overflow-hidden rounded-[14px] border border-border bg-card", className)}>
69
+ {(heading || tabs) && (
70
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border p-3.5">
71
+ {heading && (
72
+ <h3 className="text-[15px] font-bold leading-tight tracking-[-0.01em]">{heading}</h3>
73
+ )}
74
+ {caption && (
75
+ <span className="text-[12.5px] text-muted-foreground">{caption}</span>
76
+ )}
77
+ {tabs && tabs.length > 0 && (
78
+ <div className="ml-auto flex flex-wrap gap-1.5">
79
+ {tabs.map((tab) => (
80
+ <button
81
+ key={tab.slug}
82
+ type="button"
83
+ aria-pressed={tab.slug === activeTab}
84
+ onClick={() => onTabChange?.(tab.slug)}
85
+ className={cn(
86
+ "inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-semibold transition-colors",
87
+ tab.slug === activeTab
88
+ ? "border-accent bg-accent-subtle text-accent"
89
+ : "border-border text-muted-foreground hover:bg-muted/50"
90
+ )}
91
+ >
92
+ {tab.label}
93
+ </button>
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ )}
99
+
100
+ <ol>
101
+ {venues.map((venue, index) => {
102
+ const { place, signature } = venue;
103
+ const mentions = signature?.item.mentions ?? 0;
104
+
105
+ return (
106
+ <li key={place.slug}>
107
+ {index > 0 && <Separator />}
108
+ <LinkComponent
109
+ href={venue.href}
110
+ className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
111
+ >
112
+ <span className="w-4 shrink-0 text-center text-[13px] font-extrabold tabular-nums text-accent">
113
+ {index + 1}
114
+ </span>
115
+
116
+ {/* 48px. Same reason as `OpenNowList`: below ~48 the venue
117
+ photograph stops being a photograph. */}
118
+ <span className="h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
119
+ {place.thumbnail_url && (
120
+ // eslint-disable-next-line @next/next/no-img-element
121
+ <img
122
+ src={place.thumbnail_url}
123
+ alt=""
124
+ className="h-full w-full object-cover"
125
+ loading="lazy"
126
+ />
127
+ )}
128
+ </span>
129
+
130
+ <span className="min-w-0 flex-1">
131
+ {/* The whole name: a ranked list is a list of names, and a
132
+ name cut to "PASQUALE | brunch & din…" is not one. */}
133
+ <span className="block text-sm font-bold leading-snug tracking-[-0.01em]">
134
+ {place.name}
135
+ </span>
136
+ {signature?.item.name && (
137
+ <span className="mt-px block truncate font-serif text-xs italic text-muted-foreground">
138
+ {`“${signature.item.name}”`}
139
+ </span>
140
+ )}
141
+ </span>
142
+
143
+ {mentions > 0 && (
144
+ <span className="hidden shrink-0 items-center gap-1 text-[11.5px] tabular-nums text-muted-foreground sm:inline-flex">
145
+ <MessageSquare className="h-[11px] w-[11px]" aria-hidden />
146
+ {mentions}
147
+ </span>
148
+ )}
149
+
150
+ {gradeLabels?.[place.slug] ? (
151
+ <span className="inline-flex h-6 shrink-0 items-center rounded-md bg-foreground px-1.5 text-[12px] font-extrabold tabular-nums text-background">
152
+ {gradeLabels[place.slug]}
153
+ </span>
154
+ ) : (
155
+ place.rating && (
156
+ <span className="inline-flex shrink-0 items-center gap-1 text-[12.5px] font-semibold tabular-nums">
157
+ <Star className="h-3 w-3" aria-hidden />
158
+ {place.rating.rating}
159
+ </span>
160
+ )
161
+ )}
162
+
163
+ {priceBandLabels?.[place.slug] && (
164
+ <span className="hidden shrink-0 text-[12.5px] tabular-nums text-muted-foreground sm:block">
165
+ {priceBandLabels[place.slug]}
166
+ </span>
167
+ )}
168
+
169
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
170
+ </LinkComponent>
171
+ </li>
172
+ );
173
+ })}
174
+ </ol>
175
+ </div>
176
+ );
177
+ }
@@ -68,3 +68,27 @@ export const LoadingVsLoaded: Story = {
68
68
  </div>
69
69
  ),
70
70
  };
71
+
72
+ /**
73
+ * The app-card header: title, count, and an accent "All ›" in the count's
74
+ * size tier. Both the tone and the subtitle are phone-scoped — resize past
75
+ * `lg` to see the muted action and the subtitle return.
76
+ */
77
+ export const AppCard: Story = {
78
+ args: {
79
+ title: "Tonight",
80
+ count: 7,
81
+ subtitle: "Everything still to start before midnight",
82
+ subtitleFrom: "lg",
83
+ action: "All 15",
84
+ actionHref: "#",
85
+ actionTone: "app",
86
+ },
87
+ decorators: [
88
+ (Story) => (
89
+ <div className="w-[366px] rounded-[18px] bg-card px-3.5 pt-4">
90
+ <Story />
91
+ </div>
92
+ ),
93
+ ],
94
+ };
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { SectionHeader } from "./section-header";
4
+
5
+ describe("SectionHeader", () => {
6
+ it("renders title, count and a linked action", () => {
7
+ render(<SectionHeader title="Tonight" count={7} action="See all" actionHref="/events" />);
8
+ expect(screen.getByRole("heading", { name: "Tonight" })).toBeInTheDocument();
9
+ expect(screen.getByText("7")).toBeInTheDocument();
10
+ expect(screen.getByRole("link", { name: "See all" })).toHaveAttribute("href", "/events");
11
+ });
12
+
13
+ it("gives the app tone its accent classes and the muted tone none of them", () => {
14
+ const { rerender } = render(
15
+ <SectionHeader title="Tonight" action="See all" actionHref="/events" actionTone="app" />
16
+ );
17
+ expect(screen.getByRole("link", { name: "See all" }).className).toContain("text-accent");
18
+
19
+ rerender(<SectionHeader title="Tonight" action="See all" actionHref="/events" />);
20
+ expect(screen.getByRole("link", { name: "See all" }).className).not.toContain("text-accent");
21
+ });
22
+
23
+ it("keeps the subtitle in the DOM but hides it below lg with subtitleFrom", () => {
24
+ render(<SectionHeader title="Tonight" subtitle="Still to start" subtitleFrom="lg" />);
25
+ expect(screen.getByText("Still to start").className).toContain("hidden");
26
+ expect(screen.getByText("Still to start").className).toContain("lg:block");
27
+ });
28
+ });
@@ -35,6 +35,21 @@ export type SectionHeaderProps = {
35
35
  /** Trailing action label, e.g. "See all". */
36
36
  action?: string;
37
37
  actionHref?: string;
38
+ /**
39
+ * `muted` (default): 14px in `--muted-foreground`, at every width. `app`:
40
+ * BELOW `lg`, 12.5px semibold in `--accent` — the way an app's "All ›" reads
41
+ * at the end of a card's title row, quieter in size and louder in colour so
42
+ * it sits in the count's tier rather than competing with the title. From
43
+ * `lg` it falls back to `muted`: the app treatment is a phone treatment, and
44
+ * the desktop page it shares a header with is unchanged by it.
45
+ */
46
+ actionTone?: "muted" | "app";
47
+ /**
48
+ * `always` (default) shows the subtitle at every width. `lg` hides it below
49
+ * the `lg` breakpoint for surfaces that narrate on desktop and not on a
50
+ * phone, where a card title with a count is the whole header.
51
+ */
52
+ subtitleFrom?: "always" | "lg";
38
53
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
39
54
  linkComponent?: React.ComponentType<SectionHeaderLinkProps>;
40
55
  onAction?: (mouseEvent: React.MouseEvent) => void;
@@ -48,24 +63,38 @@ export type SectionHeaderProps = {
48
63
  className?: string;
49
64
  };
50
65
 
51
- const ACTION_CLASSES = cn(
52
- "inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[14px] text-muted-foreground",
53
- "transition-colors duration-150 hover:text-foreground motion-reduce:transition-none",
66
+ // `-my-2 py-2 -mr-1 pr-1`: the label keeps its visual size while its hit area
67
+ // grows to the 44px minimum. Negative margins cancel the padding so the row
68
+ // height and the right edge do not move.
69
+ const ACTION_BASE_CLASSES = cn(
70
+ "inline-flex min-h-11 shrink-0 items-center gap-0.5 rounded-sm -my-2 py-2 -mr-1 pr-1",
71
+ "transition-colors duration-150 motion-reduce:transition-none",
54
72
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
55
73
  );
56
74
 
75
+ const ACTION_TONE_CLASSES = {
76
+ muted: "text-[14px] text-muted-foreground hover:text-foreground",
77
+ app: cn(
78
+ "text-[12.5px] font-semibold text-accent hover:text-accent/80",
79
+ "lg:text-[14px] lg:font-normal lg:text-muted-foreground lg:hover:text-foreground"
80
+ ),
81
+ } as const;
82
+
57
83
  export function SectionHeader({
58
84
  title,
59
85
  count,
60
86
  subtitle,
61
87
  action,
62
88
  actionHref,
89
+ actionTone = "muted",
90
+ subtitleFrom = "always",
63
91
  linkComponent: LinkComponent = DefaultLink,
64
92
  onAction,
65
93
  as: Heading = "h2",
66
94
  className,
67
95
  }: SectionHeaderProps) {
68
96
  const actionIcon = <ChevronRight className="h-3.5 w-3.5" aria-hidden />;
97
+ const actionClasses = cn(ACTION_BASE_CLASSES, ACTION_TONE_CLASSES[actionTone]);
69
98
 
70
99
  return (
71
100
  <div className={cn("flex flex-col mb-3.5", className)}>
@@ -75,18 +104,20 @@ export function SectionHeader({
75
104
  {title}
76
105
  </Heading>
77
106
  {count !== undefined && (
78
- <span className="ml-0.5 shrink-0 text-[12px] text-muted-foreground">{count}</span>
107
+ <span className="ml-1.5 shrink-0 text-[12px] tabular-nums text-muted-foreground lg:ml-0.5">
108
+ {count}
109
+ </span>
79
110
  )}
80
111
  </div>
81
112
 
82
113
  {action &&
83
114
  (actionHref ? (
84
- <LinkComponent href={actionHref} className={ACTION_CLASSES} onClick={onAction}>
115
+ <LinkComponent href={actionHref} className={actionClasses} onClick={onAction}>
85
116
  {action}
86
117
  {actionIcon}
87
118
  </LinkComponent>
88
119
  ) : onAction ? (
89
- <button type="button" className={ACTION_CLASSES} onClick={onAction}>
120
+ <button type="button" className={actionClasses} onClick={onAction}>
90
121
  {action}
91
122
  {actionIcon}
92
123
  </button>
@@ -94,7 +125,14 @@ export function SectionHeader({
94
125
  </div>
95
126
 
96
127
  {subtitle && (
97
- <p className="mt-0.5 text-[13px] text-muted-foreground md:text-[14px]">{subtitle}</p>
128
+ <p
129
+ className={cn(
130
+ "mt-0.5 text-[13px] text-muted-foreground md:text-[14px]",
131
+ subtitleFrom === "lg" && "hidden lg:block"
132
+ )}
133
+ >
134
+ {subtitle}
135
+ </p>
98
136
  )}
99
137
  </div>
100
138
  );
@@ -0,0 +1,77 @@
1
+ import { Badge } from "./badge";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * "Signature" / "Mentioned" — the marker for a menu item the review digest
6
+ * backs, shared by `<MenuItem>` and the dish/drink cards so the same claim
7
+ * never renders two different ways.
8
+ *
9
+ * When `onEvidence` is supplied the chip becomes a button that opens the
10
+ * reviews behind it, and takes a dashed outline to read as interactive. That is
11
+ * the whole differentiator: every competitor badges items ("Nou", "NEW"), none
12
+ * can show you why. Without evidence it degrades to a plain label rather than
13
+ * offering a tap that would reveal nothing.
14
+ *
15
+ * `stopPropagation` matters on every host: the row is a `role="button"` and the
16
+ * cards carry a stretched crawlable `<a>` overlay, so an un-stopped click would
17
+ * also open the item drawer. On the cards it must additionally out-rank that
18
+ * overlay's `z-[1]`, hence `relative z-[2]` from the caller.
19
+ *
20
+ * `surface="image"` is for card covers, where the chip sits over the photo
21
+ * rather than in the caption block. The accent-on-canvas palette has no
22
+ * contrast guarantee over an arbitrary photo, so it swaps to the same frosted
23
+ * fill the place badge uses there and keeps the dashed edge — which is what
24
+ * says "there is evidence behind this" — in the inverted colour.
25
+ *
26
+ * The label is passed in: the DS owns no translation keys.
27
+ */
28
+
29
+ export type SignatureChipProps = {
30
+ /** Already translated. `strong` → "Signature", `weak` → "Mentioned". */
31
+ label: string;
32
+ onEvidence?: () => void;
33
+ /** `image` = over a card cover; `canvas` = in a caption or row. */
34
+ surface?: "canvas" | "image";
35
+ className?: string;
36
+ };
37
+
38
+ export function SignatureChip({
39
+ label,
40
+ onEvidence,
41
+ surface = "canvas",
42
+ className,
43
+ }: SignatureChipProps) {
44
+ const shared = "h-4 shrink-0 px-1.5 text-[10px] font-medium uppercase tracking-wider";
45
+ const onImage = surface === "image";
46
+
47
+ if (!onEvidence) {
48
+ return (
49
+ <Badge variant={onImage ? "overlay" : "secondary"} className={cn(shared, className)}>
50
+ {label}
51
+ </Badge>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <button
57
+ type="button"
58
+ onClick={(event) => {
59
+ event.stopPropagation();
60
+ event.preventDefault();
61
+ onEvidence();
62
+ }}
63
+ className={cn(
64
+ shared,
65
+ "inline-flex items-center rounded-full border border-dashed",
66
+ onImage
67
+ ? "border-background/70 bg-foreground/70 text-background backdrop-blur-sm hover:bg-foreground/85"
68
+ : "border-accent/40 text-accent hover:bg-accent/10",
69
+ "focus-visible:outline-none focus-visible:ring-2",
70
+ onImage ? "focus-visible:ring-background/70" : "focus-visible:ring-accent/40",
71
+ className
72
+ )}
73
+ >
74
+ {label}
75
+ </button>
76
+ );
77
+ }
@@ -0,0 +1,85 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockHubLabels, mockVenues } from "./menu-mocks";
3
+ import { SignatureLineList } from "./signature-line-list";
4
+
5
+
6
+ const itemLabels = {
7
+ currency: "lei",
8
+ mentions: (count: number) => `${count} mentions`,
9
+ signature: "SIGNATURE",
10
+ mentioned: "MENTIONED",
11
+ };
12
+ const formatPrice = (amount: number) => String(amount);
13
+
14
+ const labels = itemLabels;
15
+
16
+ const metaLabels = {
17
+ "the-copper-yard": "pub · Centru",
18
+ "salt-and-stone": "italian · Centru",
19
+ "the-quiet-hours": "coffee · Centru",
20
+ "green-room-canteen": "vegetarian · Mărăști",
21
+ };
22
+
23
+ const hues = {
24
+ "the-copper-yard": "oklch(0.52 0.15 25)",
25
+ "salt-and-stone": "oklch(0.62 0.14 55)",
26
+ "the-quiet-hours": "oklch(0.58 0.09 70)",
27
+ };
28
+
29
+
30
+ const meta: Meta<typeof SignatureLineList> = {
31
+ title: "Menu/SignatureLineList",
32
+ component: SignatureLineList,
33
+ tags: ["autodocs"],
34
+ decorators: [
35
+ (Story) => (
36
+ <div className="w-[980px] bg-background p-4">
37
+ <Story />
38
+ </div>
39
+ ),
40
+ ],
41
+ parameters: {
42
+ docs: {
43
+ description: {
44
+ component:
45
+ "Family, venue, dish, price and reputation on one line — the densest surface on the hub, and the one that most nearly justifies the whole catalogue. Two targets per row on purpose: the venue line goes to the venue, the item row goes to that item across every kitchen that makes it. Collapsing them into one tap loses the comparison that makes the item worth showing. They are separate anchors rather than a button nested in a link, which would be invalid.",
46
+ },
47
+ },
48
+ },
49
+ };
50
+
51
+ export default meta;
52
+ type Story = StoryObj<typeof SignatureLineList>;
53
+
54
+ export const Default: Story = {
55
+ args: {
56
+ venues: mockVenues.slice(0, 3),
57
+ labels,
58
+ formatPrice,
59
+ metaLabels,
60
+ hues,
61
+ },
62
+ };
63
+
64
+ /** The last venue has no signature — the row is a venue line and nothing else. */
65
+ export const MixedEvidence: Story = {
66
+ args: { venues: mockVenues, labels, formatPrice, metaLabels, hues },
67
+ };
68
+
69
+ /** Below `lg` the row stacks: venue line, then the full item row underneath. */
70
+ export const Mobile: Story = {
71
+ args: {
72
+ venues: mockVenues.slice(0, 3),
73
+ labels,
74
+ formatPrice,
75
+ metaLabels,
76
+ hues,
77
+ },
78
+ decorators: [
79
+ (Story) => (
80
+ <div className="w-[390px] bg-background p-3">
81
+ <Story />
82
+ </div>
83
+ ),
84
+ ],
85
+ };