@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,185 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { MenuLinkProps, MenuLinkComponent } from "./menu-types";
3
+ import type { PlacePreview } from "./place-types";
4
+
5
+ /**
6
+ * VenueWall — the catalogue's scale, stated as a row of the venues in it.
7
+ *
8
+ * A closing beat rather than a browse surface: the avatars are named and
9
+ * linked, but the module's job is to make "we read every menu in the city"
10
+ * land as something specific. It shows a handful and counts the rest, because
11
+ * a wall long enough to be exhaustive stops reading as places.
12
+ *
13
+ * Server-safe.
14
+ */
15
+
16
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
17
+ export type VenueWallLinkProps = MenuLinkProps;
18
+
19
+ function DefaultLink({ href, ...props }: VenueWallLinkProps) {
20
+ return <a href={href} {...props} />;
21
+ }
22
+
23
+ export type VenueWallProps = {
24
+ places: PlacePreview[];
25
+ /** Destination per place, keyed by slug. Without one an avatar is inert. */
26
+ hrefs?: Record<string, string>;
27
+ kicker?: string;
28
+ kickerIcon?: React.ReactNode;
29
+ headline?: string;
30
+ body?: string;
31
+ /** Composed overflow chip, e.g. "+393 more". */
32
+ overflowLabel?: string;
33
+ /**
34
+ * Tiles for the mosaic ground — the same licensed stills the hero uses.
35
+ * Without them the wall falls back to the flat diagonal texture, which is
36
+ * the degraded state rather than the design.
37
+ */
38
+ tileUrls?: string[];
39
+ /** The action — pass a rendered link or button. */
40
+ action?: React.ReactNode;
41
+ className?: string;
42
+ linkComponent?: MenuLinkComponent;
43
+ };
44
+
45
+ export function VenueWall({
46
+ places,
47
+ hrefs,
48
+ kicker,
49
+ kickerIcon,
50
+ headline,
51
+ body,
52
+ overflowLabel,
53
+ tileUrls,
54
+ action,
55
+ linkComponent: LinkComponent = DefaultLink,
56
+ className,
57
+ }: VenueWallProps) {
58
+ return (
59
+ <div
60
+ className={cn(
61
+ "relative flex flex-col gap-4 overflow-hidden rounded-[20px] bg-[oklch(0.21_0.012_55)] p-5 text-white lg:p-8",
62
+ className
63
+ )}
64
+ >
65
+ {tileUrls?.length ? (
66
+ <>
67
+ <div aria-hidden className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-6">
68
+ {Array.from({ length: 18 }, (_, cell) => (
69
+ // eslint-disable-next-line @next/next/no-img-element
70
+ <img
71
+ key={cell}
72
+ src={tileUrls[cell % tileUrls.length]}
73
+ alt=""
74
+ loading="lazy"
75
+ decoding="async"
76
+ className="h-full w-full object-cover"
77
+ />
78
+ ))}
79
+ </div>
80
+ {/* Heavier than the hero's: this block's copy sits over the full
81
+ width rather than in a left column. */}
82
+ <div
83
+ aria-hidden
84
+ className="absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.9)]"
85
+ />
86
+ </>
87
+ ) : (
88
+ <div
89
+ aria-hidden
90
+ className="absolute inset-0 bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.18]"
91
+ />
92
+ )}
93
+
94
+ <div className="relative z-[1] flex flex-col gap-3">
95
+ {kicker && (
96
+ <span className="inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
97
+ {kickerIcon}
98
+ {kicker}
99
+ </span>
100
+ )}
101
+
102
+ {headline && (
103
+ <h3 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.1] tracking-[-0.025em] lg:text-[40px]">
104
+ {headline}
105
+ </h3>
106
+ )}
107
+
108
+ {body && <p className="hidden max-w-[540px] text-sm leading-[1.55] opacity-85 lg:block">{body}</p>}
109
+ </div>
110
+
111
+ {/* Below `lg` the crowd is a stack: the first few faces overlapping, then
112
+ the count. Eight named chips wrapped over four lines were most of the
113
+ banner on a phone and said nothing the count does not. */}
114
+ <div aria-hidden className="relative z-[1] flex items-center gap-3 lg:hidden">
115
+ <div className="flex">
116
+ {places.slice(0, 5).map((place, index) => (
117
+ <span
118
+ key={place.slug}
119
+ className={cn(
120
+ "h-9 w-9 shrink-0 overflow-hidden rounded-full bg-white/20 ring-2 ring-[oklch(0.21_0.012_55)]",
121
+ index > 0 && "-ml-2.5"
122
+ )}
123
+ >
124
+ {place.thumbnail_url && (
125
+ // eslint-disable-next-line @next/next/no-img-element
126
+ <img src={place.thumbnail_url} alt="" className="h-full w-full object-cover" loading="lazy" />
127
+ )}
128
+ </span>
129
+ ))}
130
+ </div>
131
+ {overflowLabel && <span className="text-[13px] font-medium opacity-85">{overflowLabel}</span>}
132
+ </div>
133
+
134
+ <ul className="relative z-[1] hidden flex-wrap items-center gap-2 lg:flex">
135
+ {places.map((place) => {
136
+ const avatar = (
137
+ <>
138
+ <span className="h-7 w-7 shrink-0 overflow-hidden rounded-full bg-white/20">
139
+ {place.thumbnail_url && (
140
+ // eslint-disable-next-line @next/next/no-img-element
141
+ <img
142
+ src={place.thumbnail_url}
143
+ alt=""
144
+ className="h-full w-full object-cover"
145
+ loading="lazy"
146
+ />
147
+ )}
148
+ </span>
149
+ <span className="truncate text-[12.5px] font-medium">{place.name}</span>
150
+ </>
151
+ );
152
+
153
+ const chipClasses =
154
+ "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/10 py-1 pl-1 pr-3.5 backdrop-blur-[6px]";
155
+
156
+ return (
157
+ <li key={place.slug}>
158
+ {hrefs?.[place.slug] ? (
159
+ <LinkComponent
160
+ href={hrefs[place.slug]}
161
+ className={cn(
162
+ chipClasses,
163
+ "transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
164
+ )}
165
+ >
166
+ {avatar}
167
+ </LinkComponent>
168
+ ) : (
169
+ <span className={chipClasses}>{avatar}</span>
170
+ )}
171
+ </li>
172
+ );
173
+ })}
174
+
175
+ {overflowLabel && (
176
+ <li className="inline-flex h-9 items-center rounded-full bg-white/10 px-4 text-[12.5px] opacity-80">
177
+ {overflowLabel}
178
+ </li>
179
+ )}
180
+ </ul>
181
+
182
+ {action && <div className="relative z-[1] mt-1">{action}</div>}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,113 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Calendar, PanelRightOpen } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { WeekdayThemeBanner, type WeekdayThemeDay } from "./weekday-theme-banner";
5
+
6
+ const days: WeekdayThemeDay[] = [
7
+ { code: "MON", name: "Meatless Monday", shortName: "Meatless", count: 84 },
8
+ { code: "TUE", name: "Taco Tuesday", shortName: "Taco", count: 26 },
9
+ { code: "WED", name: "Salad Wednesday", shortName: "Salad", count: 132 },
10
+ { code: "THU", name: "Burger Thursday", shortName: "Burger", count: 186 },
11
+ { code: "FRI", name: "Pizza Friday", shortName: "Pizza", count: 214 },
12
+ { code: "SAT", name: "Brunch Saturday", shortName: "Brunch", count: 96 },
13
+ { code: "SUN", name: "Roast Sunday", shortName: "Roast", count: 41 },
14
+ ];
15
+
16
+ const drinkDays: WeekdayThemeDay[] = [
17
+ { code: "MON", name: "Mulled Monday", shortName: "Mulled", count: 22 },
18
+ { code: "TUE", name: "Tap Takeover", shortName: "Tap", count: 41 },
19
+ { code: "WED", name: "Wine Wednesday", shortName: "Wine", count: 52 },
20
+ { code: "THU", name: "Spritz Thursday", shortName: "Spritz", count: 64 },
21
+ { code: "FRI", name: "Negroni Friday", shortName: "Negroni", count: 38 },
22
+ { code: "SAT", name: "Sour Saturday", shortName: "Sour", count: 29 },
23
+ { code: "SUN", name: "Slow Coffee Sunday", shortName: "Coffee", count: 34 },
24
+ ];
25
+
26
+ const meta: Meta<typeof WeekdayThemeBanner> = {
27
+ title: "Menu/WeekdayThemeBanner",
28
+ component: WeekdayThemeBanner,
29
+ tags: ["autodocs"],
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="w-[1100px] bg-background p-4">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ parameters: {
38
+ docs: {
39
+ description: {
40
+ component:
41
+ "The day's editorial theme, with the whole week beside it. Showing all seven is the point: a banner that only says \"it is Burger Thursday\" is an assertion, while one showing Wednesday was salads and Friday is pizza reads as a standing rhythm worth coming back for — and the other six days are the only way to browse to one. `activeDay` is resolved by the CALLER from the reader's clock, because a server in UTC and a reader in Bucharest disagree about the date for two hours a day.",
42
+ },
43
+ },
44
+ },
45
+ };
46
+
47
+ export default meta;
48
+ type Story = StoryObj<typeof WeekdayThemeBanner>;
49
+
50
+ export const Default: Story = {
51
+ args: {
52
+ days,
53
+ activeDay: 3,
54
+ kicker: "TODAY IS THURSDAY",
55
+ kickerIcon: <Calendar className="h-3 w-3" />,
56
+ body: "186 of them across 11 kitchens, from 32 lei. Two do a vegetarian version and four keep the grill on past midnight.",
57
+ weekLabel: "THE WEEK",
58
+ action: (
59
+ <Button size="sm" variant="secondary">
60
+ <PanelRightOpen className="h-3.5 w-3.5" />
61
+ See today&apos;s picks
62
+ </Button>
63
+ ),
64
+ actionNote: "Bruno picks nine, ranked on review mentions",
65
+ dayHref: (day) => `/menu?theme=${day.code.toLowerCase()}`,
66
+ },
67
+ };
68
+
69
+ /** The drinks tab, same component. */
70
+ export const DrinksTab: Story = {
71
+ args: {
72
+ days: drinkDays,
73
+ activeDay: 2,
74
+ kicker: "TODAY IS WEDNESDAY",
75
+ kickerIcon: <Calendar className="h-3 w-3" />,
76
+ body: "Fifty-two by the glass across nineteen bars, from 16 lei.",
77
+ weekLabel: "THE WEEK",
78
+ },
79
+ };
80
+
81
+ /** `onDayChange` instead of `dayHref` — the rail selects in place. Needs a client caller. */
82
+ export const Selectable: Story = {
83
+ args: { days, activeDay: 4, kicker: "TODAY IS FRIDAY", weekLabel: "THE WEEK", onDayChange: () => {} },
84
+ };
85
+
86
+ /** No counts: themes without a number behind them still read. */
87
+ export const WithoutCounts: Story = {
88
+ args: {
89
+ days: days.map(({ count: _count, ...day }) => day),
90
+ activeDay: 0,
91
+ kicker: "TODAY IS MONDAY",
92
+ weekLabel: "THE WEEK",
93
+ },
94
+ };
95
+
96
+ export const Mobile: Story = {
97
+ args: {
98
+ days,
99
+ activeDay: 3,
100
+ kicker: "TODAY IS THURSDAY",
101
+ kickerIcon: <Calendar className="h-3 w-3" />,
102
+ body: "186 of them across 11 kitchens, from 32 lei.",
103
+ weekLabel: "THE WEEK",
104
+ action: <Button size="sm" variant="secondary">See today&apos;s picks</Button>,
105
+ },
106
+ decorators: [
107
+ (Story) => (
108
+ <div className="w-[390px] bg-background p-3">
109
+ <Story />
110
+ </div>
111
+ ),
112
+ ],
113
+ };
@@ -0,0 +1,231 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * WeekdayThemeBanner — the day's editorial theme, with the whole week beside it.
5
+ *
6
+ * Showing all seven days is the point: a banner that only says "it is Burger
7
+ * Thursday" is an assertion, while one that shows Wednesday was salads and
8
+ * Friday is pizza reads as a standing rhythm the reader can come back for. The
9
+ * other six days are also the only affordance for browsing to one.
10
+ *
11
+ * ## The active day is the caller's, not the component's
12
+ *
13
+ * `activeDay` is an index into `days`, resolved by the caller from the READER's
14
+ * clock. Resolving it here would use the server's, and a server in UTC and a
15
+ * reader in Bucharest disagree about which day it is for two hours out of every
16
+ * twenty-four.
17
+ *
18
+ * No directive: with `dayHref` the week rail is links and renders on the
19
+ * server; with `onDayChange` it is buttons and needs a client caller.
20
+ */
21
+
22
+ export type WeekdayThemeBannerLinkProps = {
23
+ href: string;
24
+ className?: string;
25
+ children: React.ReactNode;
26
+ };
27
+
28
+ function DefaultLink({ href, ...props }: WeekdayThemeBannerLinkProps) {
29
+ return <a href={href} {...props} />;
30
+ }
31
+
32
+ export type WeekdayThemeDay = {
33
+ /** Short code, e.g. "THU". Already localised. */
34
+ code: string;
35
+ /** Theme name, e.g. "Burger Thursday". */
36
+ name: string;
37
+ /** Short form for the rail, e.g. "Burger". Falls back to `name`. */
38
+ shortName?: string;
39
+ /** Items under the theme. Omit where there is no count to show. */
40
+ count?: number;
41
+ };
42
+
43
+ export type WeekdayThemeBannerProps = {
44
+ days: WeekdayThemeDay[];
45
+ /** Index into `days` for the reader's own today. */
46
+ activeDay: number;
47
+ /** Kicker over the title, e.g. "TODAY IS THURSDAY". Composed by the caller. */
48
+ kicker?: string;
49
+ kickerIcon?: React.ReactNode;
50
+ /** The banner's body copy for the active day. */
51
+ body?: string;
52
+ /** Heading over the week rail, e.g. "THE WEEK". */
53
+ weekLabel?: string;
54
+ /** The primary action — pass a rendered button or link. */
55
+ action?: React.ReactNode;
56
+ /** Note beside the action, e.g. "Bruno picks nine, ranked on mentions". */
57
+ actionNote?: string;
58
+ /** Destination per day index. Renders the rail as links. */
59
+ dayHref?: (day: WeekdayThemeDay, index: number) => string;
60
+ /** Selects a day in place. Renders the rail as buttons. */
61
+ onDayChange?: (index: number) => void;
62
+ /**
63
+ * The day's photograph.
64
+ *
65
+ * This is the one editorial slot on a menu hub that carries a plated dish,
66
+ * and it can because these are art-directed assets shipped with the app, not
67
+ * generated item photos pulled off a menu row. Without one the banner falls
68
+ * back to its flat ground and still reads — but the flat version is the
69
+ * degraded state, not the design.
70
+ */
71
+ imageUrl?: string;
72
+ linkComponent?: React.ComponentType<WeekdayThemeBannerLinkProps>;
73
+ className?: string;
74
+ };
75
+
76
+ export function WeekdayThemeBanner({
77
+ days,
78
+ activeDay,
79
+ kicker,
80
+ kickerIcon,
81
+ body,
82
+ weekLabel,
83
+ action,
84
+ actionNote,
85
+ dayHref,
86
+ onDayChange,
87
+ imageUrl,
88
+ linkComponent: LinkComponent = DefaultLink,
89
+ className,
90
+ }: WeekdayThemeBannerProps) {
91
+ const active = days[activeDay];
92
+ if (!active) return null;
93
+
94
+ return (
95
+ <div
96
+ className={cn(
97
+ "relative grid overflow-hidden rounded-[18px] bg-[linear-gradient(120deg,oklch(0.32_0.06_45),oklch(0.24_0.04_40))] text-white lg:grid-cols-[1fr_auto]",
98
+ className
99
+ )}
100
+ >
101
+ {imageUrl && (
102
+ <>
103
+ {/* eslint-disable-next-line @next/next/no-img-element */}
104
+ <img
105
+ src={imageUrl}
106
+ alt=""
107
+ aria-hidden
108
+ className="absolute inset-0 h-full w-full object-cover"
109
+ />
110
+ {/*
111
+ The scrim covers the WHOLE banner, not just the text side.
112
+ It used to fade to near-transparent on the right, which looked
113
+ better in isolation and made the week rail — seven lines of 12px
114
+ type — sit directly on whatever the plate happened to be. A photo
115
+ you can half-read under is worth less than seven labels you can
116
+ read. Still a gradient rather than a flat wash, so the image keeps
117
+ some depth on the right.
118
+ */}
119
+ <div
120
+ aria-hidden
121
+ className="absolute inset-0 bg-[linear-gradient(100deg,oklch(0.18_0.03_45_/_0.94)_0%,oklch(0.18_0.03_45_/_0.82)_45%,oklch(0.18_0.03_45_/_0.66)_100%)]"
122
+ />
123
+ </>
124
+ )}
125
+
126
+ {!imageUrl && (
127
+ <span
128
+ aria-hidden
129
+ className="pointer-events-none absolute bottom-0 right-[28%] select-none font-serif text-[140px] font-bold leading-none text-white/[0.06]"
130
+ >
131
+ {active.code}
132
+ </span>
133
+ )}
134
+
135
+ <div className="relative z-[1] flex flex-col gap-2.5 p-4 lg:gap-3 lg:p-6">
136
+ {kicker && (
137
+ <span className="inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[10.5px] font-bold tracking-[0.09em]">
138
+ {kickerIcon}
139
+ {kicker}
140
+ </span>
141
+ )}
142
+
143
+ <h3 className="font-serif text-[24px] font-semibold tracking-[-0.02em] lg:text-[36px]">
144
+ {active.name}
145
+ </h3>
146
+
147
+ {body && <p className="max-w-[520px] text-[13.5px] leading-[1.5] opacity-90">{body}</p>}
148
+
149
+ {(action || actionNote) && (
150
+ <div className="mt-1 flex flex-wrap items-center gap-3">
151
+ {action}
152
+ {actionNote && <span className="text-xs opacity-75">{actionNote}</span>}
153
+ </div>
154
+ )}
155
+ </div>
156
+
157
+ <div
158
+ className={cn(
159
+ "relative z-[1] hidden border-t border-white/15 p-4 lg:block lg:min-w-[240px] lg:border-l lg:border-t-0 lg:p-5",
160
+ // Over a photograph the rail needs its own ground, or seven lines of
161
+ // small type land on whatever the plate happens to be.
162
+ imageUrl && "bg-black/45 backdrop-blur-[2px]"
163
+ )}
164
+ >
165
+ {weekLabel && (
166
+ <div className="mb-2 text-[10px] font-bold tracking-[0.12em] opacity-70">
167
+ {weekLabel}
168
+ </div>
169
+ )}
170
+
171
+ <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4 lg:grid-cols-1">
172
+ {days.map((day, index) => {
173
+ const isActive = index === activeDay;
174
+ const content = (
175
+ <>
176
+ <b className="w-8 shrink-0 font-mono text-[11px] font-medium opacity-70">
177
+ {day.code}
178
+ </b>
179
+ <span className="min-w-0 flex-1 truncate">{day.shortName ?? day.name}</span>
180
+ {day.count != null && (
181
+ <span className="shrink-0 tabular-nums opacity-70">{day.count}</span>
182
+ )}
183
+ </>
184
+ );
185
+
186
+ // Today is set BIGGER, not just bolder. Weight alone was doing all
187
+ // the work at 12.5px against six neighbours, which is not enough
188
+ // separation to answer "which one is today" at a glance — the
189
+ // question the rail exists to answer.
190
+ const rowClasses = cn(
191
+ "flex w-full items-center gap-2 rounded transition-opacity",
192
+ isActive
193
+ ? "-mx-1.5 bg-white/10 px-1.5 py-1.5 text-[14.5px] font-extrabold"
194
+ : "py-[5px] text-[12.5px] opacity-70 hover:opacity-100"
195
+ );
196
+
197
+ return (
198
+ <li key={day.code}>
199
+ {dayHref ? (
200
+ <LinkComponent
201
+ href={dayHref(day, index)}
202
+ className={cn(
203
+ rowClasses,
204
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
205
+ )}
206
+ >
207
+ {content}
208
+ </LinkComponent>
209
+ ) : onDayChange ? (
210
+ <button
211
+ type="button"
212
+ aria-pressed={isActive}
213
+ onClick={() => onDayChange(index)}
214
+ className={cn(
215
+ rowClasses,
216
+ "text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
217
+ )}
218
+ >
219
+ {content}
220
+ </button>
221
+ ) : (
222
+ <span className={rowClasses}>{content}</span>
223
+ )}
224
+ </li>
225
+ );
226
+ })}
227
+ </ul>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { WeekendPanel } from "./weekend-panel";
4
+
5
+ const labels = { pause: "Pause", play: "Play" };
6
+
7
+ function renderPanel() {
8
+ return render(
9
+ <WeekendPanel dayLabel="Saturday" countLabel="4 events" actionLabel="See all" labels={labels}>
10
+ <a href="/a">A</a>
11
+ <a href="/b">B</a>
12
+ <a href="/c">C</a>
13
+ <a href="/d">D</a>
14
+ </WeekendPanel>
15
+ );
16
+ }
17
+
18
+ const track = () => screen.getByText("A").parentElement!.parentElement as HTMLElement;
19
+
20
+ describe("WeekendPanel", () => {
21
+ it("has no autoplay control below the desktop breakpoint", () => {
22
+ renderPanel();
23
+ expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument();
24
+ });
25
+
26
+ it("starts on the first page and turns on a leftward drag", () => {
27
+ renderPanel();
28
+ expect(track().style.transform).toContain("calc(0 *");
29
+
30
+ const stage = track().parentElement as HTMLElement;
31
+ fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
32
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
33
+ fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
34
+ fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
35
+ expect(track().style.transform).toContain("calc(-1 *");
36
+ });
37
+
38
+ it("turns to the page of a card that receives focus", () => {
39
+ renderPanel();
40
+ fireEvent.focus(screen.getByText("D"));
41
+ expect(track().style.transform).toContain("calc(-1 *");
42
+ });
43
+ });