@bearmenu/ui 0.8.4 → 0.8.6

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 (157) hide show
  1. package/dist/chunk-637L7XQ3.js +85 -0
  2. package/dist/chunk-6XMYEZVR.js +26 -0
  3. package/dist/chunk-DNSXMHYX.js +8 -0
  4. package/dist/chunk-FDITZ2VM.js +16 -0
  5. package/dist/chunk-GPH6L4DX.js +55 -0
  6. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  7. package/dist/chunk-TT3WFS6S.js +379 -0
  8. package/dist/chunk-UXWAOISG.js +170 -0
  9. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  10. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  11. package/dist/components/alert.d.ts +2 -2
  12. package/dist/components/badge.d.ts +1 -1
  13. package/dist/components/button.d.ts +1 -1
  14. package/dist/components/card.d.ts +2 -2
  15. package/dist/components/card.js +4 -127
  16. package/dist/components/category-deck.d.ts +50 -0
  17. package/dist/components/category-deck.js +128 -0
  18. package/dist/components/command.d.ts +15 -15
  19. package/dist/components/concept-card.d.ts +43 -0
  20. package/dist/components/concept-card.js +81 -0
  21. package/dist/components/coverflow.d.ts +123 -0
  22. package/dist/components/coverflow.js +300 -0
  23. package/dist/components/date-input.d.ts +1 -1
  24. package/dist/components/day-rail-chip.d.ts +82 -0
  25. package/dist/components/day-rail-chip.js +105 -0
  26. package/dist/components/drift-rail.d.ts +28 -0
  27. package/dist/components/drift-rail.js +3 -0
  28. package/dist/components/event-card.d.ts +97 -0
  29. package/dist/components/event-card.js +161 -0
  30. package/dist/components/event-compact-row.d.ts +72 -0
  31. package/dist/components/event-compact-row.js +133 -0
  32. package/dist/components/event-day-header.d.ts +65 -0
  33. package/dist/components/event-day-header.js +95 -0
  34. package/dist/components/event-day-separator.d.ts +46 -0
  35. package/dist/components/event-day-separator.js +50 -0
  36. package/dist/components/event-detail-panel.d.ts +80 -0
  37. package/dist/components/event-detail-panel.js +251 -0
  38. package/dist/components/event-hub-hero.d.ts +34 -0
  39. package/dist/components/event-hub-hero.js +57 -0
  40. package/dist/components/event-index-row.d.ts +68 -0
  41. package/dist/components/event-index-row.js +84 -0
  42. package/dist/components/event-list-row-wide.d.ts +101 -0
  43. package/dist/components/event-list-row-wide.js +218 -0
  44. package/dist/components/event-list-row.d.ts +82 -0
  45. package/dist/components/event-list-row.js +173 -0
  46. package/dist/components/event-poster-fallback.d.ts +20 -0
  47. package/dist/components/event-poster-fallback.js +3 -0
  48. package/dist/components/event-types.d.ts +118 -0
  49. package/dist/components/event-types.js +2 -0
  50. package/dist/components/events-empty-state.d.ts +44 -0
  51. package/dist/components/events-empty-state.js +73 -0
  52. package/dist/components/facet-group.d.ts +93 -0
  53. package/dist/components/facet-group.js +222 -0
  54. package/dist/components/featured-rail-item.d.ts +42 -0
  55. package/dist/components/featured-rail-item.js +88 -0
  56. package/dist/components/hour-band.d.ts +68 -0
  57. package/dist/components/hour-band.js +53 -0
  58. package/dist/components/hour-group.d.ts +67 -0
  59. package/dist/components/hour-group.js +59 -0
  60. package/dist/components/item.d.ts +2 -2
  61. package/dist/components/motion-icon-controls.js +3 -379
  62. package/dist/components/multi-select-combobox.js +1 -1
  63. package/dist/components/place-about-tab.js +3 -3
  64. package/dist/components/place-details-mobile.js +3 -3
  65. package/dist/components/poster-stack.d.ts +30 -0
  66. package/dist/components/poster-stack.js +104 -0
  67. package/dist/components/poster-tape.d.ts +36 -0
  68. package/dist/components/poster-tape.js +64 -0
  69. package/dist/components/poster-wall.d.ts +25 -0
  70. package/dist/components/poster-wall.js +183 -0
  71. package/dist/components/quiet-stretch.d.ts +43 -0
  72. package/dist/components/quiet-stretch.js +54 -0
  73. package/dist/components/section-header.d.ts +53 -0
  74. package/dist/components/section-header.js +63 -0
  75. package/dist/components/segmented-nav.d.ts +62 -0
  76. package/dist/components/segmented-nav.js +54 -0
  77. package/dist/components/skeleton-card.js +1 -1
  78. package/dist/components/skeleton.d.ts +17 -0
  79. package/dist/components/skeleton.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag-group.js +3 -26
  82. package/dist/components/tag.d.ts +2 -2
  83. package/dist/components/tag.js +1 -1
  84. package/dist/components/text.d.ts +3 -3
  85. package/dist/components/timeline-band.d.ts +46 -0
  86. package/dist/components/timeline-band.js +5 -0
  87. package/dist/components/venue-events-block.d.ts +143 -0
  88. package/dist/components/venue-events-block.js +222 -0
  89. package/dist/components/weekend-panel.d.ts +44 -0
  90. package/dist/components/weekend-panel.js +231 -0
  91. package/package.json +1 -1
  92. package/src/components/card.tsx +44 -1
  93. package/src/components/category-deck.stories.tsx +96 -0
  94. package/src/components/category-deck.tsx +227 -0
  95. package/src/components/concept-card.stories.tsx +80 -0
  96. package/src/components/concept-card.tsx +143 -0
  97. package/src/components/coverflow.stories.tsx +240 -0
  98. package/src/components/coverflow.test.tsx +193 -0
  99. package/src/components/coverflow.tsx +550 -0
  100. package/src/components/day-rail-chip.stories.tsx +145 -0
  101. package/src/components/day-rail-chip.tsx +201 -0
  102. package/src/components/drift-rail.stories.tsx +92 -0
  103. package/src/components/drift-rail.tsx +86 -0
  104. package/src/components/event-card.stories.tsx +248 -0
  105. package/src/components/event-card.test.tsx +177 -0
  106. package/src/components/event-card.tsx +321 -0
  107. package/src/components/event-compact-row.stories.tsx +221 -0
  108. package/src/components/event-compact-row.tsx +268 -0
  109. package/src/components/event-day-header.stories.tsx +124 -0
  110. package/src/components/event-day-header.tsx +180 -0
  111. package/src/components/event-day-separator.stories.tsx +70 -0
  112. package/src/components/event-day-separator.tsx +95 -0
  113. package/src/components/event-detail-panel.stories.tsx +191 -0
  114. package/src/components/event-detail-panel.tsx +360 -0
  115. package/src/components/event-hub-hero.stories.tsx +66 -0
  116. package/src/components/event-hub-hero.tsx +110 -0
  117. package/src/components/event-index-row.stories.tsx +116 -0
  118. package/src/components/event-index-row.tsx +181 -0
  119. package/src/components/event-list-row-wide.stories.tsx +204 -0
  120. package/src/components/event-list-row-wide.tsx +386 -0
  121. package/src/components/event-list-row.stories.tsx +159 -0
  122. package/src/components/event-list-row.tsx +335 -0
  123. package/src/components/event-poster-fallback.stories.tsx +52 -0
  124. package/src/components/event-poster-fallback.tsx +31 -0
  125. package/src/components/event-types.ts +133 -0
  126. package/src/components/events-empty-state.stories.tsx +59 -0
  127. package/src/components/events-empty-state.tsx +131 -0
  128. package/src/components/facet-group.stories.tsx +247 -0
  129. package/src/components/facet-group.test.tsx +225 -0
  130. package/src/components/facet-group.tsx +352 -0
  131. package/src/components/featured-rail-item.stories.tsx +86 -0
  132. package/src/components/featured-rail-item.tsx +153 -0
  133. package/src/components/hour-band.stories.tsx +159 -0
  134. package/src/components/hour-band.tsx +126 -0
  135. package/src/components/hour-group.stories.tsx +100 -0
  136. package/src/components/hour-group.tsx +124 -0
  137. package/src/components/poster-stack.stories.tsx +68 -0
  138. package/src/components/poster-stack.tsx +155 -0
  139. package/src/components/poster-tape.stories.tsx +70 -0
  140. package/src/components/poster-tape.tsx +122 -0
  141. package/src/components/poster-wall.stories.tsx +95 -0
  142. package/src/components/poster-wall.tsx +220 -0
  143. package/src/components/quiet-stretch.stories.tsx +62 -0
  144. package/src/components/quiet-stretch.tsx +115 -0
  145. package/src/components/section-header.stories.tsx +70 -0
  146. package/src/components/section-header.tsx +134 -0
  147. package/src/components/segmented-nav.stories.tsx +118 -0
  148. package/src/components/segmented-nav.tsx +113 -0
  149. package/src/components/skeleton.tsx +18 -0
  150. package/src/components/tag.tsx +11 -1
  151. package/src/components/timeline-band.stories.tsx +206 -0
  152. package/src/components/timeline-band.tsx +207 -0
  153. package/src/components/venue-events-block.stories.tsx +216 -0
  154. package/src/components/venue-events-block.tsx +408 -0
  155. package/src/components/weekend-panel.stories.tsx +92 -0
  156. package/src/components/weekend-panel.tsx +322 -0
  157. package/src/globals.css +19 -0
@@ -0,0 +1,550 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
7
+
8
+ /**
9
+ * Coverflow — the busiest-events browser: a 3D perspective lane with a large
10
+ * lead poster flanked by up to three shrinking, desaturating posters on each
11
+ * side. The lane itself drifts a few pixels back and forth
12
+ * (`prefers-reduced-motion` stops this outright; an `IntersectionObserver`
13
+ * pauses it off-screen).
14
+ *
15
+ * IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
16
+ * SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
17
+ * seven items and `activeIndex` at 0, item 6 renders at offset −1 (just left
18
+ * of the lead) rather than +6, so both flanks are full at every index instead
19
+ * of the stage emptying out at the two ends, and prev/next keep going past
20
+ * either end. `goTo` already reported a wrapped index; the visuals now agree
21
+ * with it.
22
+ *
23
+ * IDENTITY IS THE ANIMATION: a card renders keyed by `item.id`, and its
24
+ * appearance (position, rotation, scale, depth filter) is a pure function of
25
+ * that offset. Advancing `activeIndex` shifts every offset by one, and
26
+ * because the DOM node stays put the browser tweens it from its old slot to
27
+ * its new one instead of popping straight there.
28
+ *
29
+ * THE WRAP MUST NOT TWEEN. Exactly one card per step crosses the ring's seam
30
+ * — with seven items it goes from offset −3 to +3 — and animating that would
31
+ * drag a poster right across the face of the coverflow. The mechanism chosen:
32
+ * `Coverflow` keeps the previous and current active index in one piece of
33
+ * state (`step`), so a render can tell, per card, whether its offset moved by
34
+ * the same amount the active index did. A card that did not (the only way
35
+ * that happens is crossing the seam) renders WITHOUT its transition classes
36
+ * for that one commit, so the transform applies instantly; the next commit
37
+ * restores them and it tweens normally from its new slot. Two alternatives
38
+ * were rejected: re-keying the card to force a remount throws away the
39
+ * decoded `<img>` and flashes the poster, and an effect-based "clear the
40
+ * transition after paint" lands a frame too late — the slide has already
41
+ * started. The seam sits at the deepest tier, which is also the lowest
42
+ * `z-index`, so the teleport happens behind the stack and off-stage.
43
+ *
44
+ * ONLY THE VISIBLE TIERS ARE MOUNTED. Every item now resolves to some ring
45
+ * offset, so the component renders only `|offset| <= 3` and drops the rest
46
+ * entirely — a fifty-item list paints seven posters, not fifty. Cards enter
47
+ * and leave at tier 3, parked well outside the stage's horizontal clip, so the
48
+ * mount/unmount is never seen. The stage clips on the x axis ONLY; see the
49
+ * note on the container element for why the y axis must stay visible.
50
+ *
51
+ * DEGENERATE LENGTHS: with one item there is no ring, so the arrows are not
52
+ * rendered at all rather than shown inert — see the note below on controls
53
+ * that look operable and do nothing. With two items the shortest path ties at
54
+ * `n / 2`; `ringOffset` resolves the tie to the POSITIVE side, so the other
55
+ * card is always on the right flank instead of flipping between them.
56
+ *
57
+ * `prefers-reduced-motion` already removes the position transition outright
58
+ * (`motion-reduce:transition-none`), so under it every step is a jump and the
59
+ * wrap is not a special case — it cannot read as a flash because nothing else
60
+ * is animating either.
61
+ *
62
+ * ACCESSIBILITY: every item carries a `title`, so every card is a real,
63
+ * focusable control (the injected link when it carries an `href`, otherwise a
64
+ * `<button>`) named after the event it depicts — a keyboard or screen-reader
65
+ * user can jump straight to any visible card the way a mouse user clicks one
66
+ * directly, not just step with `prevLabel`/`nextLabel`. Selecting one calls
67
+ * `onActiveIndexChange` with its index. Cards beyond the visible tiers are
68
+ * not rendered at all, so a screen-reader/keyboard user never lands on an
69
+ * invisible control — there is nothing there to land on. The depth
70
+ * styling (scale/grayscale/opacity) stays purely decorative, applied to an
71
+ * inner media element rather than the focusable wrapper — `filter` creates a
72
+ * new stacking context that would otherwise dim a `box-shadow`-based focus
73
+ * ring along with the poster. The active item's title/meta stays
74
+ * `aria-live="polite"` so a screen-reader user is told what changed after any
75
+ * kind of jump.
76
+ */
77
+ export type CoverflowItem = {
78
+ /** Stable identity — this is what makes the position change animate rather than pop. */
79
+ id: string;
80
+ title: string;
81
+ /** Caller-composed, e.g. "Fri 4 · 90 going". */
82
+ meta: string;
83
+ posterUrl?: string;
84
+ href?: string;
85
+ /**
86
+ * Top-left overlay slot, rendered exactly as given — bring your own pill,
87
+ * icon and colour. Deliberately unstyled: a wrapper here would cap every
88
+ * caller at one look, and the first real consumer needed a colour-ramped
89
+ * badge the old black pill could not express.
90
+ */
91
+ badge?: React.ReactNode;
92
+ isFree?: boolean;
93
+ };
94
+
95
+ export type CoverflowLinkProps = {
96
+ href: string;
97
+ className?: string;
98
+ style?: React.CSSProperties;
99
+ children: React.ReactNode;
100
+ onClick?: (mouseEvent: React.MouseEvent) => void;
101
+ "aria-label"?: string;
102
+ "aria-hidden"?: boolean;
103
+ tabIndex?: number;
104
+ };
105
+
106
+ function DefaultLink({ href, ...props }: CoverflowLinkProps) {
107
+ return <a href={href} {...props} />;
108
+ }
109
+
110
+ export type CoverflowProps = {
111
+ items: CoverflowItem[];
112
+ activeIndex: number;
113
+ onActiveIndexChange?: (index: number) => void;
114
+ /**
115
+ * Optional centred caption under the stage, e.g. a pluralized item count
116
+ * ("7 events"). Omit it when `positionLabel` already says everything there
117
+ * is to say — passing a position string here as well renders it twice, with
118
+ * a separator dot between the two identical halves.
119
+ */
120
+ caption?: string;
121
+ /** Required to render the free pill on an item — no label, no pill. */
122
+ freeLabel: string;
123
+ /** Accessible name for the previous-item control. */
124
+ prevLabel: string;
125
+ /** Accessible name for the next-item control. */
126
+ nextLabel: string;
127
+ /** e.g. "3 of 7" — caller-composed, locale-dependent. Shown next to `caption`. */
128
+ positionLabel?: string;
129
+ linkComponent?: React.ComponentType<CoverflowLinkProps>;
130
+ className?: string;
131
+ };
132
+
133
+ /**
134
+ * Depth tiers beyond the lead, indexed by `Math.min(|offset|, 3)`. Index 0 is
135
+ * unused for rotation/scale (the lead is handled separately, scaling up from
136
+ * the shared card box instead of down) but keeps every array the same shape.
137
+ */
138
+ const COVERFLOW_MAX_VISIBLE_TIER = 3;
139
+ const COVERFLOW_TIER_ROTATE_DEG = [0, 20, 30, 38] as const;
140
+ const COVERFLOW_TIER_SCALE = [1, 0.92, 0.82, 0.72] as const;
141
+ const COVERFLOW_TIER_GRAYSCALE = [0, 0, 0.35, 0.5] as const;
142
+ const COVERFLOW_TIER_OPACITY = [1, 0.88, 0.7, 0.5] as const;
143
+
144
+ /**
145
+ * Every card — lead included — shares this DOM box, which is what keeps a
146
+ * change of tier part of the one continuous transform rather than a discrete,
147
+ * unanimatable layout jump the moment a card crosses into (or out of) the lead
148
+ * slot.
149
+ *
150
+ * THE BOX IS THE LEAD'S SIZE, AND THAT IS WHY THE LEAD IS SHARP. It used to be
151
+ * the SIDE card's size, with the lead scaled UP to reach its on-screen width.
152
+ * A browser rasterises a layer at its layout size and then stretches that
153
+ * texture, so the one card a reader actually looks at was the only one being
154
+ * resampled upward — soft by construction. The poster was never the cause: it
155
+ * is fetched well above display width. Sizing the box to the lead puts the lead
156
+ * at scale 1 and makes every other tier scale DOWN, which resamples cleanly.
157
+ */
158
+ const COVERFLOW_CARD_WIDTH_CLASS = "w-[149px] sm:w-[210px]";
159
+
160
+ /** Keeps the focus ring on the un-filtered wrapper (button/link) and the
161
+ * grayscale/opacity depth effect on the inner media div — see the
162
+ * ACCESSIBILITY note above. */
163
+ const COVERFLOW_CARD_TRANSFORM_TRANSITION_CLASS =
164
+ "transition-[transform,box-shadow] duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none motion-reduce:duration-0";
165
+ const COVERFLOW_MEDIA_FILTER_TRANSITION_CLASS =
166
+ "transition-[filter,opacity] duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none motion-reduce:duration-0";
167
+ const COVERFLOW_CARD_FOCUS_CLASS =
168
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
169
+ /**
170
+ * Scaled with the cards (see the width class). These are authored at the size
171
+ * they PAINT now: the lead no longer carries a `scale()`, so its shadow is no
172
+ * longer multiplied by one — the old `0 30px 70px` on a 1.75x-scaled element
173
+ * was really a 175px tail, which is what drove it through the stage's bottom
174
+ * edge.
175
+ */
176
+ const COVERFLOW_SIDE_SHADOW_CLASS = "shadow-[0_11px_27px_oklch(0.3_0.02_60/0.22)]";
177
+ const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32)]";
178
+ const COVERFLOW_PILL_CLASS =
179
+ "inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
180
+
181
+ const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
182
+ const COVERFLOW_DRIFT_PIXELS = 40;
183
+
184
+ /** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
185
+ * win, no matter how many cards are mid-transition and briefly promoted. */
186
+ const COVERFLOW_LEAD_Z_INDEX = 6;
187
+
188
+ const COVERFLOW_ARROW_CLASS =
189
+ "absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
190
+
191
+ function wrapIndex(index: number, length: number): number {
192
+ return ((index % length) + length) % length;
193
+ }
194
+
195
+ /**
196
+ * Signed shortest way round the ring: `0`, `±1` … up to `±floor(length / 2)`.
197
+ * A tie (`raw === length / 2`, reachable only on an even-length ring, most
198
+ * visibly the two-item one) resolves to the POSITIVE side, so that card is
199
+ * always on the right flank rather than oscillating between the two.
200
+ */
201
+ function ringOffset(delta: number, length: number): number {
202
+ const raw = wrapIndex(delta, length);
203
+ return raw > length / 2 ? raw - length : raw;
204
+ }
205
+
206
+ type CoverflowCardProps = {
207
+ item: CoverflowItem;
208
+ index: number;
209
+ /** Signed ring offset from the lead — always within `±COVERFLOW_MAX_VISIBLE_TIER`. */
210
+ offset: number;
211
+ /** Set for the single card crossing the ring's seam this commit — see the wrap note above. */
212
+ suppressTransition: boolean;
213
+ freeLabel: string;
214
+ onActivate: (index: number) => void;
215
+ linkComponent: React.ComponentType<CoverflowLinkProps>;
216
+ };
217
+
218
+ function CoverflowCard({
219
+ item,
220
+ index,
221
+ offset,
222
+ suppressTransition,
223
+ freeLabel,
224
+ onActivate,
225
+ linkComponent: LinkComponent,
226
+ }: CoverflowCardProps) {
227
+ const isLead = offset === 0;
228
+ const magnitude = Math.abs(offset);
229
+ const tier = Math.min(magnitude, COVERFLOW_MAX_VISIBLE_TIER);
230
+ const sign = Math.sign(offset);
231
+
232
+ const wrapperStyle: React.CSSProperties = {
233
+ // The lead sits at `--cf-scale-lead`, which is now 1 — see the width class.
234
+ // Side tiers multiply their depth ratio by `--cf-tier-base`, the side/lead
235
+ // width ratio, because the shared box is the LEAD's size: without it every
236
+ // side card would render at nearly lead width. It is a CSS var rather than
237
+ // a constant here because the ratio differs per breakpoint, and calc()
238
+ // cannot divide two lengths to derive it live.
239
+ transform: isLead
240
+ ? "translate(-50%, -50%) scale(var(--cf-scale-lead))"
241
+ : `translate(-50%, -50%) translateX(calc(${sign} * var(--cf-x${tier}))) rotateY(${-sign * COVERFLOW_TIER_ROTATE_DEG[tier]}deg) scale(calc(var(--cf-tier-base) * ${COVERFLOW_TIER_SCALE[tier]}))`,
242
+ zIndex: Math.max(COVERFLOW_LEAD_Z_INDEX - magnitude, 0),
243
+ };
244
+
245
+ const mediaStyle: React.CSSProperties = {
246
+ opacity: COVERFLOW_TIER_OPACITY[tier],
247
+ filter:
248
+ COVERFLOW_TIER_GRAYSCALE[tier] > 0
249
+ ? `grayscale(${COVERFLOW_TIER_GRAYSCALE[tier] * 100}%)`
250
+ : "none",
251
+ };
252
+
253
+ const media = (
254
+ <div
255
+ className={cn(
256
+ "relative aspect-[4/5] overflow-hidden rounded-[10px] bg-muted",
257
+ !suppressTransition && COVERFLOW_MEDIA_FILTER_TRANSITION_CLASS
258
+ )}
259
+ style={mediaStyle}
260
+ >
261
+ {item.posterUrl ? (
262
+ // eslint-disable-next-line @next/next/no-img-element
263
+ <img
264
+ src={item.posterUrl}
265
+ alt=""
266
+ loading={isLead ? "eager" : "lazy"}
267
+ decoding="async"
268
+ className="h-full w-full object-cover"
269
+ />
270
+ ) : (
271
+ <EventPosterFallback />
272
+ )}
273
+
274
+ <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
275
+ {/* The badge renders AS GIVEN — no pill of ours around it.
276
+ It used to be wrapped in `COVERFLOW_PILL_CLASS bg-black/62
277
+ text-white`, which meant a caller could only ever produce a black
278
+ pill here. The events hub's hype badge is a colour-ramped
279
+ speedometer on a brand gradient, so to survive that wrapper it had
280
+ been hand-rolled down to a monochrome flame — the carousel showed a
281
+ flat grey badge where every other event surface showed the real one.
282
+ A caller wanting the old look styles it itself; `isFree` below keeps
283
+ its pill because that one IS the component's own. */}
284
+ {item.badge ?? <span />}
285
+ {item.isFree && freeLabel && (
286
+ <span className={cn(COVERFLOW_PILL_CLASS, "bg-success font-bold text-success-foreground")}>
287
+ <Gift className="h-3 w-3" aria-hidden />
288
+ {freeLabel}
289
+ </span>
290
+ )}
291
+ </div>
292
+ </div>
293
+ );
294
+
295
+ const wrapperClassName = cn(
296
+ "absolute left-1/2 top-1/2 block bg-transparent p-0 text-left",
297
+ COVERFLOW_CARD_WIDTH_CLASS,
298
+ isLead ? "cursor-default" : "cursor-pointer",
299
+ isLead ? COVERFLOW_LEAD_SHADOW_CLASS : COVERFLOW_SIDE_SHADOW_CLASS,
300
+ "rounded-[10px]",
301
+ !suppressTransition && COVERFLOW_CARD_TRANSFORM_TRANSITION_CLASS,
302
+ COVERFLOW_CARD_FOCUS_CLASS
303
+ );
304
+
305
+ // The lead never needs to reselect itself — clicking it should only follow
306
+ // its own `href`, same as before.
307
+ const handleActivate = isLead ? undefined : () => onActivate(index);
308
+
309
+ if (item.href) {
310
+ return (
311
+ <LinkComponent
312
+ href={item.href}
313
+ className={wrapperClassName}
314
+ style={wrapperStyle}
315
+ aria-label={item.title}
316
+ onClick={handleActivate}
317
+ >
318
+ {media}
319
+ </LinkComponent>
320
+ );
321
+ }
322
+
323
+ return (
324
+ <button
325
+ type="button"
326
+ className={wrapperClassName}
327
+ style={wrapperStyle}
328
+ aria-label={item.title}
329
+ onClick={handleActivate}
330
+ >
331
+ {media}
332
+ </button>
333
+ );
334
+ }
335
+
336
+ export function Coverflow({
337
+ items,
338
+ activeIndex,
339
+ onActiveIndexChange,
340
+ caption,
341
+ freeLabel,
342
+ prevLabel,
343
+ nextLabel,
344
+ positionLabel,
345
+ linkComponent: LinkComponent = DefaultLink,
346
+ className,
347
+ }: CoverflowProps) {
348
+ const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
349
+ const scopeClass = `coverflow-${scopeId}`;
350
+ const containerRef = useRef<HTMLDivElement>(null);
351
+
352
+ const length = items.length;
353
+ const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
354
+
355
+ /**
356
+ * The previous and current active index, as one value. Kept in state rather
357
+ * than a ref because a ref written during render gives the wrong answer on
358
+ * StrictMode's second pass, and because this has to be readable DURING the
359
+ * render that repositions the cards: it is what tells a card whether it moved
360
+ * with the stage or jumped the seam, and the answer decides whether that
361
+ * card's transition classes are applied at all.
362
+ */
363
+ const [step, setStep] = useState({ from: active, to: active, length });
364
+ if (step.to !== active || step.length !== length) {
365
+ // React re-runs the component and throws this pass away, so `step` is
366
+ // already correct by the time anything commits. A length change resets the
367
+ // pair — offsets computed against a different ring size mean nothing.
368
+ setStep(
369
+ step.length === length
370
+ ? { from: step.to, to: active, length }
371
+ : { from: active, to: active, length }
372
+ );
373
+ }
374
+
375
+ useEffect(() => {
376
+ const node = containerRef.current;
377
+ if (!node || typeof IntersectionObserver === "undefined") return undefined;
378
+
379
+ const observer = new IntersectionObserver(
380
+ ([entry]) => {
381
+ node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
382
+ },
383
+ { threshold: 0 }
384
+ );
385
+ observer.observe(node);
386
+ return () => observer.disconnect();
387
+ }, []);
388
+
389
+ if (length === 0) return null;
390
+
391
+ const activeItem = items[active];
392
+ // No clamp at the ends: prev from 0 reports `length - 1` and next from
393
+ // `length - 1` reports 0, so the index the parent stores matches the ring.
394
+ const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
395
+
396
+ /** How far the lead itself moved, the short way round — 0 on a settled render. */
397
+ const activeDelta = ringOffset(step.to - step.from, length);
398
+
399
+ const visibleCards = items
400
+ .map((item, index) => {
401
+ const offset = ringOffset(index - active, length);
402
+ // A card that moved by anything other than `activeDelta` did not travel
403
+ // with the stage — it came round the back. That is the wrap, and the only
404
+ // thing that produces it.
405
+ const suppressTransition = offset !== ringOffset(index - step.from, length) - activeDelta;
406
+ return { item, index, offset, suppressTransition };
407
+ })
408
+ .filter(({ offset }) => Math.abs(offset) <= COVERFLOW_MAX_VISIBLE_TIER);
409
+
410
+ const hasRing = length > 1;
411
+
412
+ return (
413
+ <div className={cn(scopeClass, "w-full", className)}>
414
+ <style>{`
415
+ .${scopeClass} {
416
+ /*
417
+ * Every card shares one DOM box (COVERFLOW_CARD_WIDTH_CLASS above),
418
+ * and that box is now the LEAD's width. These are the visual
419
+ * footprints the JS transform math targets.
420
+ *
421
+ * --cf-scale-lead is therefore 1: the lead paints at its own size and
422
+ * is never resampled upward, which is what fixed it reading soft.
423
+ * --cf-tier-base is --cf-side-w / --cf-lead-w, the factor every side
424
+ * tier multiplies its depth ratio by; it must be kept in sync by hand
425
+ * if either width changes, because calc() cannot divide two lengths.
426
+ * 77 / 149 = 0.5168 120 / 210 = 0.5714
427
+ *
428
+ * Both footprints are 20% down from where they started (186/96 and
429
+ * 262/150): the lead was large enough that a poster meant for a feed
430
+ * thumbnail was being asked to carry a hero, and it showed.
431
+ */
432
+ --cf-lead-w: 149px;
433
+ --cf-side-w: 77px;
434
+ --cf-gap: 21px;
435
+ --cf-scale-lead: 1;
436
+ --cf-tier-base: 0.5168;
437
+ --cf-x1: calc(var(--cf-lead-w) / 2 + var(--cf-gap) + var(--cf-side-w) / 2);
438
+ --cf-x2: calc(var(--cf-x1) + var(--cf-side-w) + var(--cf-gap));
439
+ --cf-x3: calc(var(--cf-x2) + var(--cf-side-w) + var(--cf-gap));
440
+ }
441
+ @media (min-width: 640px) {
442
+ .${scopeClass} {
443
+ --cf-lead-w: 210px;
444
+ --cf-side-w: 120px;
445
+ --cf-gap: 26px;
446
+ --cf-tier-base: 0.5714;
447
+ }
448
+ }
449
+ .${scopeClass} .coverflow-lane {
450
+ animation: ${scopeClass}-drift ${COVERFLOW_DRIFT_DURATION_SECONDS}s ease-in-out infinite alternate;
451
+ }
452
+ .${scopeClass}[data-offscreen="true"] .coverflow-lane {
453
+ animation-play-state: paused;
454
+ }
455
+ @keyframes ${scopeClass}-drift {
456
+ from { transform: translateX(-${COVERFLOW_DRIFT_PIXELS}px); }
457
+ to { transform: translateX(${COVERFLOW_DRIFT_PIXELS}px); }
458
+ }
459
+ @media (prefers-reduced-motion: reduce) {
460
+ .${scopeClass} .coverflow-lane {
461
+ animation: none;
462
+ transform: none;
463
+ }
464
+ }
465
+ `}</style>
466
+
467
+ <div
468
+ ref={containerRef}
469
+ // CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
470
+ // round the back of the ring, and that only ever happens off the left
471
+ // and right edges — but `overflow-hidden` clips both axes, so it was
472
+ // also slicing a hard horizontal line through the cards' drop shadows
473
+ // at the top and bottom edges.
474
+ //
475
+ // `overflow-x: clip` rather than `hidden` is the load-bearing part:
476
+ // `hidden` forces the other axis to compute to `auto` (a scroll
477
+ // container), so `overflow-x-hidden overflow-y-visible` is not a thing
478
+ // the cascade will give you. `clip` carries no such rule, so the pair
479
+ // below means what it says.
480
+ className="relative flex h-[300px] items-center justify-center overflow-x-clip overflow-y-visible [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
481
+ >
482
+ <div
483
+ aria-hidden="true"
484
+ className="pointer-events-none absolute inset-y-0 left-0 z-10 w-[120px] bg-gradient-to-r from-background to-transparent"
485
+ />
486
+ <div
487
+ aria-hidden="true"
488
+ className="pointer-events-none absolute inset-y-0 right-0 z-10 w-[120px] bg-gradient-to-l from-background to-transparent"
489
+ />
490
+
491
+ {/* One item is not a ring — an arrow here would be a control that
492
+ looks operable and does nothing, so there is no arrow. */}
493
+ {hasRing && (
494
+ <>
495
+ <button
496
+ type="button"
497
+ aria-label={prevLabel}
498
+ onClick={() => goTo(active - 1)}
499
+ className={cn(COVERFLOW_ARROW_CLASS, "left-2 sm:left-4")}
500
+ >
501
+ <ChevronLeft className="h-5 w-5" aria-hidden />
502
+ </button>
503
+ <button
504
+ type="button"
505
+ aria-label={nextLabel}
506
+ onClick={() => goTo(active + 1)}
507
+ className={cn(COVERFLOW_ARROW_CLASS, "right-2 sm:right-4")}
508
+ >
509
+ <ChevronRight className="h-5 w-5" aria-hidden />
510
+ </button>
511
+ </>
512
+ )}
513
+
514
+ <div className="coverflow-lane relative h-full w-full [transform-style:preserve-3d] motion-reduce:[animation:none]">
515
+ {visibleCards.map(({ item, index, offset, suppressTransition }) => (
516
+ <CoverflowCard
517
+ key={item.id}
518
+ item={item}
519
+ index={index}
520
+ offset={offset}
521
+ suppressTransition={suppressTransition}
522
+ freeLabel={freeLabel}
523
+ onActivate={goTo}
524
+ linkComponent={LinkComponent}
525
+ />
526
+ ))}
527
+ </div>
528
+ </div>
529
+
530
+ <div className="mt-4 space-y-4 text-center">
531
+ <div aria-live="polite">
532
+ <h3 className="text-[17px] font-extrabold">{activeItem.title}</h3>
533
+ <p className="mt-[3px] text-[12.5px] text-muted-foreground">{activeItem.meta}</p>
534
+ </div>
535
+
536
+ {/* The dot is a separator, not decoration: it only exists when there
537
+ are in fact two things to separate. With one of them absent the
538
+ row collapses to a single centred span, no dangling glyph, no gap
539
+ — and the whole row disappears when neither is given. */}
540
+ {(caption || positionLabel) && (
541
+ <div className="flex items-center justify-center gap-2 text-[12.5px] text-muted-foreground">
542
+ {caption && <span>{caption}</span>}
543
+ {caption && positionLabel && <span aria-hidden="true">·</span>}
544
+ {positionLabel && <span>{positionLabel}</span>}
545
+ </div>
546
+ )}
547
+ </div>
548
+ </div>
549
+ );
550
+ }
@@ -0,0 +1,145 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { DayRailChip, DayRailChipSkeleton } from "./day-rail-chip";
3
+ import type { DayRailChipLabels } from "./event-types";
4
+
5
+ const labels: DayRailChipLabels = { noEvents: "No events" };
6
+
7
+ const meta: Meta<typeof DayRailChip> = {
8
+ title: "Events/DayRailChip",
9
+ component: DayRailChip,
10
+ tags: ["autodocs"],
11
+ args: { labels },
12
+ parameters: {
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "One day in the timeline's day rail. An empty day keeps its place in the rail with an em dash and no density dot — ten of fourteen days routinely read empty, and dropping them would make the rail lie about how far ahead it reaches.",
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof DayRailChip>;
24
+
25
+ export const Active: Story = {
26
+ args: { label: "Today", dayNumber: "3", count: 15, isActive: true },
27
+ };
28
+
29
+ export const HasEvents: Story = {
30
+ args: { label: "Fri", dayNumber: "4", count: 17 },
31
+ };
32
+
33
+ /** Weekend days keep the bold weight while inactive, so they stay findable. */
34
+ export const Weekend: Story = {
35
+ args: { label: "Sat", dayNumber: "5", count: 23, isWeekend: true },
36
+ };
37
+
38
+ /** No dot, an em dash rather than "0", muted label — but still in the rail. */
39
+ export const Empty: Story = {
40
+ args: { label: "Fri", dayNumber: "11", count: 0 },
41
+ };
42
+
43
+ /** The full fortnight, as the rail actually renders it. */
44
+ export const TheRail: Story = {
45
+ render: () => {
46
+ const days: Array<[string, string, number, boolean]> = [
47
+ ["Today", "3", 15, false],
48
+ ["Fri", "4", 17, true],
49
+ ["Sat", "5", 23, true],
50
+ ["Sun", "6", 10, true],
51
+ ["Mon", "7", 5, false],
52
+ ["Tue", "8", 3, false],
53
+ ["Wed", "9", 3, false],
54
+ ["Thu", "10", 2, false],
55
+ ["Fri", "11", 0, true],
56
+ ["Sat", "12", 4, true],
57
+ ["Sun", "13", 3, true],
58
+ ["Mon", "14", 0, false],
59
+ ];
60
+ return (
61
+ <div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
62
+ {days.map(([label, dayNumber, count, isWeekend], i) => (
63
+ <DayRailChip
64
+ key={`${label}-${dayNumber}`}
65
+ label={label}
66
+ dayNumber={dayNumber}
67
+ count={count}
68
+ isWeekend={isWeekend}
69
+ isActive={i === 0}
70
+ labels={labels}
71
+ onClick={() => {}}
72
+ />
73
+ ))}
74
+ </div>
75
+ );
76
+ },
77
+ };
78
+
79
+ /** Loading state — one chip's footprint (62×50, 12px radius). */
80
+ export const Loading: Story = {
81
+ render: () => <DayRailChipSkeleton />,
82
+ };
83
+
84
+ /** The full fortnight, loading — six chips at the rail's own `gap-2` (8px). */
85
+ export const LoadingRail: Story = {
86
+ render: () => (
87
+ <div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
88
+ {Array.from({ length: 6 }).map((_, index) => (
89
+ <DayRailChipSkeleton key={index} />
90
+ ))}
91
+ </div>
92
+ ),
93
+ };
94
+
95
+ /** A loading rail beside the loaded one, so any geometry drift is visible at a glance. */
96
+ export const LoadingVsLoaded: Story = {
97
+ render: () => (
98
+ <div className="space-y-2">
99
+ <div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
100
+ {Array.from({ length: 6 }).map((_, index) => (
101
+ <DayRailChipSkeleton key={index} />
102
+ ))}
103
+ </div>
104
+ <div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
105
+ <DayRailChip label="Today" dayNumber="3" count={15} isActive labels={labels} onClick={() => {}} />
106
+ <DayRailChip label="Fri" dayNumber="4" count={17} labels={labels} onClick={() => {}} />
107
+ <DayRailChip label="Sat" dayNumber="5" count={23} isWeekend labels={labels} onClick={() => {}} />
108
+ <DayRailChip label="Sun" dayNumber="6" count={10} isWeekend labels={labels} onClick={() => {}} />
109
+ <DayRailChip label="Mon" dayNumber="7" count={5} labels={labels} onClick={() => {}} />
110
+ <DayRailChip label="Tue" dayNumber="8" count={3} labels={labels} onClick={() => {}} />
111
+ </div>
112
+ </div>
113
+ ),
114
+ };
115
+
116
+ /** With a filter applied, counts redraw and most days fall to zero. */
117
+ export const FilteredToFreeEntry: Story = {
118
+ render: () => {
119
+ const days: Array<[string, string, number]> = [
120
+ ["Today", "3", 2],
121
+ ["Fri", "4", 3],
122
+ ["Sat", "5", 2],
123
+ ["Sun", "6", 0],
124
+ ["Mon", "7", 1],
125
+ ["Tue", "8", 0],
126
+ ["Wed", "9", 1],
127
+ ["Thu", "10", 0],
128
+ ];
129
+ return (
130
+ <div className="flex max-w-[390px] gap-2 overflow-x-auto px-4 pb-1">
131
+ {days.map(([label, dayNumber, count], i) => (
132
+ <DayRailChip
133
+ key={`${label}-${dayNumber}`}
134
+ label={label}
135
+ dayNumber={dayNumber}
136
+ count={count}
137
+ isActive={i === 2}
138
+ labels={labels}
139
+ onClick={() => {}}
140
+ />
141
+ ))}
142
+ </div>
143
+ );
144
+ },
145
+ };