@bearmenu/ui 0.8.6 → 0.8.8

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 (88) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-AHL45DD2.js +145 -0
  3. package/dist/chunk-IYDH5IU6.js +66 -0
  4. package/dist/chunk-OPVKL4VS.js +18 -0
  5. package/dist/components/alert.d.ts +2 -2
  6. package/dist/components/badge.d.ts +1 -1
  7. package/dist/components/button.d.ts +1 -1
  8. package/dist/components/card.d.ts +2 -2
  9. package/dist/components/category-deck.js +10 -1
  10. package/dist/components/command.d.ts +15 -15
  11. package/dist/components/coverflow.d.ts +11 -3
  12. package/dist/components/coverflow.js +34 -51
  13. package/dist/components/drift-rail.d.ts +24 -10
  14. package/dist/components/drift-rail.js +1 -1
  15. package/dist/components/event-card.d.ts +8 -0
  16. package/dist/components/event-card.js +68 -35
  17. package/dist/components/event-compact-row.d.ts +34 -6
  18. package/dist/components/event-compact-row.js +120 -38
  19. package/dist/components/event-day-header.js +3 -1
  20. package/dist/components/event-day-separator.d.ts +7 -1
  21. package/dist/components/event-day-separator.js +3 -2
  22. package/dist/components/event-hub-hero.d.ts +25 -4
  23. package/dist/components/event-hub-hero.js +89 -19
  24. package/dist/components/event-list-row.d.ts +19 -6
  25. package/dist/components/event-list-row.js +78 -34
  26. package/dist/components/event-types.d.ts +5 -1
  27. package/dist/components/hour-band.d.ts +38 -40
  28. package/dist/components/hour-band.js +55 -27
  29. package/dist/components/hour-group.js +1 -1
  30. package/dist/components/item.d.ts +2 -2
  31. package/dist/components/poster-tape.js +1 -1
  32. package/dist/components/poster-wall.d.ts +3 -3
  33. package/dist/components/poster-wall.js +73 -17
  34. package/dist/components/section-header.d.ts +16 -1
  35. package/dist/components/section-header.js +26 -7
  36. package/dist/components/snap-rail.d.ts +34 -0
  37. package/dist/components/snap-rail.js +51 -0
  38. package/dist/components/spinner.d.ts +2 -2
  39. package/dist/components/tag.d.ts +2 -2
  40. package/dist/components/text.d.ts +3 -3
  41. package/dist/components/timeline-band.d.ts +20 -2
  42. package/dist/components/timeline-band.js +1 -1
  43. package/dist/components/timeline-playhead.d.ts +26 -0
  44. package/dist/components/timeline-playhead.js +25 -0
  45. package/dist/components/weekend-panel.d.ts +5 -4
  46. package/dist/components/weekend-panel.js +85 -71
  47. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  48. package/dist/hooks/use-drag-swipe.js +2 -0
  49. package/dist/hooks/use-media-query.d.ts +17 -0
  50. package/dist/hooks/use-media-query.js +2 -0
  51. package/package.json +1 -1
  52. package/src/components/category-deck.tsx +19 -8
  53. package/src/components/coverflow.test.tsx +28 -1
  54. package/src/components/coverflow.tsx +37 -43
  55. package/src/components/drift-rail.tsx +167 -42
  56. package/src/components/event-card.test.tsx +11 -0
  57. package/src/components/event-card.tsx +67 -14
  58. package/src/components/event-compact-row.stories.tsx +30 -0
  59. package/src/components/event-compact-row.test.tsx +100 -0
  60. package/src/components/event-compact-row.tsx +166 -40
  61. package/src/components/event-day-header.tsx +3 -1
  62. package/src/components/event-day-separator.tsx +8 -1
  63. package/src/components/event-hub-hero.stories.tsx +30 -0
  64. package/src/components/event-hub-hero.tsx +102 -30
  65. package/src/components/event-list-row.stories.tsx +34 -0
  66. package/src/components/event-list-row.test.tsx +53 -0
  67. package/src/components/event-list-row.tsx +135 -87
  68. package/src/components/event-types.ts +5 -1
  69. package/src/components/hour-band.stories.tsx +28 -5
  70. package/src/components/hour-band.tsx +90 -70
  71. package/src/components/poster-wall.test.tsx +28 -0
  72. package/src/components/poster-wall.tsx +96 -23
  73. package/src/components/section-header.stories.tsx +24 -0
  74. package/src/components/section-header.test.tsx +28 -0
  75. package/src/components/section-header.tsx +45 -7
  76. package/src/components/snap-rail.stories.tsx +79 -0
  77. package/src/components/snap-rail.test.tsx +18 -0
  78. package/src/components/snap-rail.tsx +67 -0
  79. package/src/components/timeline-band.tsx +52 -12
  80. package/src/components/timeline-playhead.stories.tsx +30 -0
  81. package/src/components/timeline-playhead.test.tsx +11 -0
  82. package/src/components/timeline-playhead.tsx +46 -0
  83. package/src/components/weekend-panel.test.tsx +43 -0
  84. package/src/components/weekend-panel.tsx +142 -108
  85. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  86. package/src/hooks/use-drag-swipe.ts +105 -0
  87. package/src/hooks/use-media-query.ts +31 -0
  88. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,17 +1,18 @@
1
+ import { useMediaQuery, DESKTOP_MEDIA_QUERY } from '../chunk-OPVKL4VS.js';
1
2
  import { MotionPlayPause } from '../chunk-TT3WFS6S.js';
2
3
  import '../chunk-2WDJR6K7.js';
4
+ import { useDragSwipe } from '../chunk-IYDH5IU6.js';
3
5
  import { Skeleton } from '../chunk-QXHMPJ35.js';
4
6
  import { cn } from '../chunk-FEK5XGZW.js';
5
- import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
7
+ import { __spreadProps, __spreadValues, __objRest } from '../chunk-3GWWZKX7.js';
6
8
  import * as React from 'react';
7
9
  import { ChevronRight } from 'lucide-react';
8
10
  import { jsxs, jsx } from 'react/jsx-runtime';
9
11
 
10
12
  var WEEKEND_PANEL_TRACK_GAP_PX = 14;
11
13
  var WEEKEND_PANEL_DEFAULT_DURATION_SECONDS = 7;
12
- var WEEKEND_PANEL_SLIDE_KEYFRAMES_ID = "ds-weekend-panel-slide";
13
- var WEEKEND_PANEL_DOT_A_KEYFRAMES_ID = "ds-weekend-panel-dot-a";
14
- var WEEKEND_PANEL_DOT_B_KEYFRAMES_ID = "ds-weekend-panel-dot-b";
14
+ var WEEKEND_PANEL_PAGE_COUNT = 2;
15
+ var WEEKEND_PANEL_CARDS_PER_PAGE = 2;
15
16
  function usePrefersReducedMotion() {
16
17
  const [reduced, setReduced] = React.useState(false);
17
18
  React.useEffect(() => {
@@ -30,9 +31,12 @@ function useInViewport() {
30
31
  React.useEffect(() => {
31
32
  const node = ref.current;
32
33
  if (!node || typeof IntersectionObserver === "undefined") return;
33
- const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
34
- threshold: 0
35
- });
34
+ const observer = new IntersectionObserver(
35
+ ([entry]) => setInView(entry.isIntersecting),
36
+ {
37
+ threshold: 0
38
+ }
39
+ );
36
40
  observer.observe(node);
37
41
  return () => observer.disconnect();
38
42
  }, []);
@@ -56,16 +60,36 @@ function WeekendPanel({
56
60
  className
57
61
  }) {
58
62
  const prefersReducedMotion = usePrefersReducedMotion();
63
+ const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
59
64
  const [panelRef, inView] = useInViewport();
60
65
  const [isHovered, setIsHovered] = React.useState(false);
61
66
  const [isFocused, setIsFocused] = React.useState(false);
62
67
  const [isManuallyPaused, setIsManuallyPaused] = React.useState(false);
63
68
  const [playPauseReplayToken, setPlayPauseReplayToken] = React.useState(0);
64
- const isPaused = isManuallyPaused || isHovered || isFocused || !inView;
69
+ const [page, setPage] = React.useState(0);
70
+ const autoplays = isDesktop && !prefersReducedMotion;
71
+ const isPaused = !autoplays || isManuallyPaused || isHovered || isFocused || !inView;
72
+ React.useEffect(() => {
73
+ if (isPaused) return void 0;
74
+ const interval = window.setInterval(
75
+ () => setPage((current) => (current + 1) % WEEKEND_PANEL_PAGE_COUNT),
76
+ durationSeconds * 1e3 / WEEKEND_PANEL_PAGE_COUNT
77
+ );
78
+ return () => window.clearInterval(interval);
79
+ }, [durationSeconds, isPaused]);
80
+ const turnTo = (next) => {
81
+ setPage(Math.max(0, Math.min(WEEKEND_PANEL_PAGE_COUNT - 1, next)));
82
+ setIsManuallyPaused(true);
83
+ };
84
+ const { dragX, isDragging, handlers } = useDragSwipe({
85
+ onSwipe: (direction) => turnTo(page + direction),
86
+ disabled: prefersReducedMotion
87
+ });
65
88
  const action = /* @__PURE__ */ jsxs("span", { className: "ml-auto flex items-center gap-0.5 text-[12.5px] text-muted-foreground", children: [
66
89
  actionLabel,
67
90
  /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5", "aria-hidden": true })
68
91
  ] });
92
+ const cards = React.Children.toArray(children);
69
93
  return /* @__PURE__ */ jsxs(
70
94
  "div",
71
95
  {
@@ -80,25 +104,6 @@ function WeekendPanel({
80
104
  className
81
105
  ),
82
106
  children: [
83
- /* @__PURE__ */ jsx("style", { children: `
84
- @keyframes ${WEEKEND_PANEL_SLIDE_KEYFRAMES_ID} {
85
- 0%, 45% { transform: translateX(0); }
86
- 50%, 95% { transform: translateX(calc(-100% - ${WEEKEND_PANEL_TRACK_GAP_PX}px)); }
87
- 100% { transform: translateX(0); }
88
- }
89
- @keyframes ${WEEKEND_PANEL_DOT_A_KEYFRAMES_ID} {
90
- 0% { background: var(--primary); width: 18px; }
91
- 45% { background: var(--primary); width: 18px; }
92
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
93
- 100% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
94
- }
95
- @keyframes ${WEEKEND_PANEL_DOT_B_KEYFRAMES_ID} {
96
- 0% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
97
- 50% { background: color-mix(in oklch, var(--muted-foreground) 35%, transparent); width: 6px; }
98
- 95% { background: var(--primary); width: 18px; }
99
- 100% { background: var(--primary); width: 18px; }
100
- }
101
- ` }),
102
107
  /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-[9px] px-4 pb-3", children: [
103
108
  /* @__PURE__ */ jsx(
104
109
  "span",
@@ -130,51 +135,57 @@ function WeekendPanel({
130
135
  }
131
136
  ) : action
132
137
  ] }),
133
- prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) : /* @__PURE__ */ jsx("div", { className: "overflow-hidden px-4", children: /* @__PURE__ */ jsx(
134
- "div",
135
- {
136
- className: "flex gap-3.5",
137
- style: {
138
- animationName: WEEKEND_PANEL_SLIDE_KEYFRAMES_ID,
139
- animationDuration: `${durationSeconds}s`,
140
- animationTimingFunction: "ease-in-out",
141
- animationIterationCount: "infinite",
142
- animationPlayState: isPaused ? "paused" : "running"
143
- },
144
- children: React.Children.map(children, (child) => /* @__PURE__ */ jsx("div", { className: "w-[calc((100%-14px)/2)] flex-none", children: child }))
145
- }
146
- ) }),
138
+ prefersReducedMotion ? /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-3.5 px-4", children }) : (
139
+ // Padding OUTSIDE the clip. `overflow-hidden` clips at the padding
140
+ // edge, so with the padding on the clipping element the next page's
141
+ // first card showed through the 16px inset — a sliver of the slide
142
+ // cutting in. The reader should see exactly the two cards of a page.
143
+ /* @__PURE__ */ jsx("div", { className: "px-4", children: /* @__PURE__ */ jsx(
144
+ "div",
145
+ __spreadProps(__spreadValues({}, handlers), {
146
+ className: "overflow-hidden select-none [touch-action:pan-y]",
147
+ children: /* @__PURE__ */ jsx(
148
+ "div",
149
+ {
150
+ className: cn(
151
+ "flex gap-3.5",
152
+ !isDragging && "transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
153
+ ),
154
+ style: {
155
+ transform: `translateX(calc(${-page} * (100% + ${WEEKEND_PANEL_TRACK_GAP_PX}px) + ${dragX}px))`
156
+ },
157
+ children: cards.map((child, index) => /* @__PURE__ */ jsx(
158
+ "div",
159
+ {
160
+ className: "w-[calc((100%-14px)/2)] flex-none",
161
+ onFocus: () => {
162
+ const cardPage = Math.floor(
163
+ index / WEEKEND_PANEL_CARDS_PER_PAGE
164
+ );
165
+ if (cardPage !== page) turnTo(cardPage);
166
+ },
167
+ children: child
168
+ },
169
+ index
170
+ ))
171
+ }
172
+ )
173
+ })
174
+ ) })
175
+ ),
147
176
  !prefersReducedMotion && /* @__PURE__ */ jsxs("div", { className: "mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center px-4", children: [
148
177
  /* @__PURE__ */ jsx("span", { "aria-hidden": true }),
149
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-1.5", "aria-hidden": true, children: [
150
- /* @__PURE__ */ jsx(
151
- "span",
152
- {
153
- className: "block h-1.5 w-[18px] rounded-full bg-primary",
154
- style: {
155
- animationName: WEEKEND_PANEL_DOT_A_KEYFRAMES_ID,
156
- animationDuration: `${durationSeconds}s`,
157
- animationTimingFunction: "ease-in-out",
158
- animationIterationCount: "infinite",
159
- animationPlayState: isPaused ? "paused" : "running"
160
- }
161
- }
162
- ),
163
- /* @__PURE__ */ jsx(
164
- "span",
165
- {
166
- className: "block h-1.5 w-1.5 rounded-full bg-muted-foreground/35",
167
- style: {
168
- animationName: WEEKEND_PANEL_DOT_B_KEYFRAMES_ID,
169
- animationDuration: `${durationSeconds}s`,
170
- animationTimingFunction: "ease-in-out",
171
- animationIterationCount: "infinite",
172
- animationPlayState: isPaused ? "paused" : "running"
173
- }
174
- }
175
- )
176
- ] }),
177
- /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
178
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center gap-1.5", "aria-hidden": true, children: Array.from({ length: WEEKEND_PANEL_PAGE_COUNT }, (_, index) => /* @__PURE__ */ jsx(
179
+ "span",
180
+ {
181
+ className: cn(
182
+ "block h-1.5 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none",
183
+ index === page ? "w-[18px] bg-primary" : "w-1.5 bg-muted-foreground/35"
184
+ )
185
+ },
186
+ index
187
+ )) }),
188
+ /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: autoplays && /* @__PURE__ */ jsx(
178
189
  "button",
179
190
  {
180
191
  type: "button",
@@ -198,7 +209,10 @@ function WeekendPanel({
198
209
  }
199
210
  );
200
211
  }
201
- function WeekendPanelSkeleton({ isHighlighted, className }) {
212
+ function WeekendPanelSkeleton({
213
+ isHighlighted,
214
+ className
215
+ }) {
202
216
  return /* @__PURE__ */ jsxs(
203
217
  "div",
204
218
  {
@@ -0,0 +1,44 @@
1
+ import { PointerEvent } from 'react';
2
+
3
+ /**
4
+ * useDragSwipe — horizontal drag-to-step for a stage that is not a native
5
+ * scroller (a coverflow ring, a paged track). Pointer events, so one code path
6
+ * covers touch, pen and mouse.
7
+ *
8
+ * Returns the live drag distance (`dragX`, for the stage to follow the finger
9
+ * a little while dragging) and the handlers to spread onto the stage. On
10
+ * release past `threshold` it calls `onSwipe(-1 | 1)` — `1` for a leftward
11
+ * drag, i.e. "next". `didDrag` is set for the tick after a real drag so the
12
+ * stage's `onClickCapture` can swallow the click a drag would otherwise
13
+ * become on a card underneath.
14
+ *
15
+ * The stage keeps vertical scrolling: pair these with `touch-action: pan-y`
16
+ * so the browser owns vertical gestures and hands only horizontal ones here.
17
+ * Pointer capture is taken on move, not on down, so a tap still reaches the
18
+ * card underneath as a plain click.
19
+ *
20
+ * `onDragStart` is part of the handlers for a reason: the cards under a stage
21
+ * are usually links wrapping images, and a mouse drag on either starts a
22
+ * NATIVE HTML5 drag, which cancels the pointer stream mid-gesture — the swipe
23
+ * silently never completes. Preventing it keeps the pointer events flowing.
24
+ */
25
+ type DragSwipeOptions = {
26
+ /** Distance in px that counts as a swipe. Default 40. */
27
+ threshold?: number;
28
+ onSwipe: (direction: -1 | 1) => void;
29
+ disabled?: boolean;
30
+ };
31
+ declare function useDragSwipe({ threshold, onSwipe, disabled }: DragSwipeOptions): {
32
+ dragX: number;
33
+ isDragging: boolean;
34
+ handlers: {
35
+ onPointerDown: (event: PointerEvent<HTMLElement>) => void;
36
+ onPointerMove: (event: PointerEvent<HTMLElement>) => void;
37
+ onPointerUp: (event: PointerEvent<HTMLElement>) => void;
38
+ onPointerCancel: (event: PointerEvent<HTMLElement>) => void;
39
+ onClickCapture: (event: PointerEvent<HTMLElement> | React.MouseEvent) => void;
40
+ onDragStart: (event: React.DragEvent<HTMLElement>) => void;
41
+ };
42
+ };
43
+
44
+ export { type DragSwipeOptions, useDragSwipe };
@@ -0,0 +1,2 @@
1
+ export { useDragSwipe } from '../chunk-IYDH5IU6.js';
2
+ import '../chunk-3GWWZKX7.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * useMediaQuery — a boolean for a CSS media query that is hydration-safe.
3
+ *
4
+ * `useSyncExternalStore` with a fixed server snapshot (`false`) means the
5
+ * server and the hydrating client render the same tree; the real value
6
+ * arrives on the next render. Components that switch behaviour per breakpoint
7
+ * (not just classes) need this rather than `window.matchMedia` in an effect,
8
+ * which flashes the wrong variant for a frame.
9
+ *
10
+ * Mobile-first: `false` on the server and until measured, so the phone
11
+ * variant is the one that prerenders.
12
+ */
13
+ declare function useMediaQuery(query: string): boolean;
14
+ /** The design system's desktop breakpoint, Tailwind's `lg`. */
15
+ declare const DESKTOP_MEDIA_QUERY = "(min-width: 1024px)";
16
+
17
+ export { DESKTOP_MEDIA_QUERY, useMediaQuery };
@@ -0,0 +1,2 @@
1
+ export { DESKTOP_MEDIA_QUERY, useMediaQuery } from '../chunk-OPVKL4VS.js';
2
+ import '../chunk-3GWWZKX7.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -31,7 +31,11 @@ const DECK_POSTER_COUNT = 4;
31
31
  const DECK_TRANSITION =
32
32
  "transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none";
33
33
 
34
- const DECK_CARD_TRANSFORMS: ReadonlyArray<{ rest: string; spread: string; z: string }> = [
34
+ const DECK_CARD_TRANSFORMS: ReadonlyArray<{
35
+ rest: string;
36
+ spread: string;
37
+ z: string;
38
+ }> = [
35
39
  {
36
40
  rest: "[transform:translateX(-50%)_rotate(-9deg)_translateX(-26px)]",
37
41
  spread:
@@ -115,13 +119,12 @@ export function CategoryDeck({
115
119
  }: CategoryDeckProps) {
116
120
  const slots = Array.from(
117
121
  { length: DECK_POSTER_COUNT },
118
- (_, index): CategoryDeckPoster => posters[index] ?? {}
122
+ (_, index): CategoryDeckPoster => posters[index] ?? {},
119
123
  );
120
124
 
121
125
  const body = (
122
126
  <>
123
127
  <div className="relative h-[150px] sm:h-[186px]" aria-hidden>
124
-
125
128
  {slots.map((poster, index) => {
126
129
  const transform = DECK_CARD_TRANSFORMS[index];
127
130
  return (
@@ -129,12 +132,15 @@ export function CategoryDeck({
129
132
  key={index}
130
133
  className={cn(
131
134
  "absolute left-1/2 top-0 aspect-[4/5] w-24 overflow-hidden rounded-[10px] bg-muted",
135
+ // Keyed to --foreground so the shadow is ink in light mode; in
136
+ // dark mode that same 18% is a light halo, so it drops to 7%.
132
137
  "shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_18%,transparent)]",
138
+ "dark:shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_7%,transparent)]",
133
139
  "sm:w-[120px]",
134
140
  transform.rest,
135
141
  transform.spread,
136
142
  transform.z,
137
- DECK_TRANSITION
143
+ DECK_TRANSITION,
138
144
  )}
139
145
  >
140
146
  {poster.content ? (
@@ -161,9 +167,14 @@ export function CategoryDeck({
161
167
  <div className="truncate text-[14.5px] font-extrabold tracking-[-0.015em] sm:text-base">
162
168
  {name}
163
169
  </div>
164
- <div className="truncate font-mono text-[11.5px] text-muted-foreground">{meta}</div>
170
+ <div className="truncate font-mono text-[11.5px] text-muted-foreground">
171
+ {meta}
172
+ </div>
165
173
  </div>
166
- <ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
174
+ <ChevronRight
175
+ className="ml-auto h-4 w-4 shrink-0 text-muted-foreground"
176
+ aria-hidden
177
+ />
167
178
  </div>
168
179
  </>
169
180
  );
@@ -172,7 +183,7 @@ export function CategoryDeck({
172
183
  "group flex min-w-0 flex-col text-left",
173
184
  (href || onClick) &&
174
185
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
175
- className
186
+ className,
176
187
  );
177
188
 
178
189
  if (href) {
@@ -209,7 +220,7 @@ export function CategoryDeckSkeleton({ className }: CategoryDeckSkeletonProps) {
209
220
  className={cn(
210
221
  "absolute left-1/2 top-0 aspect-[4/5] w-24 rounded-[10px] sm:w-[120px]",
211
222
  transform.rest,
212
- transform.z
223
+ transform.z,
213
224
  )}
214
225
  />
215
226
  ))}
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import { Coverflow, type CoverflowItem } from "./coverflow";
5
5
 
@@ -175,6 +175,33 @@ describe("Coverflow", () => {
175
175
  });
176
176
  });
177
177
 
178
+ describe("the drag", () => {
179
+ it("steps forward on a leftward drag and back on a rightward one", () => {
180
+ const onActiveIndexChange = vi.fn();
181
+ renderCoverflow({ activeIndex: 2, onActiveIndexChange });
182
+ const stage = screen.getByRole("button", { name: "Event 2" }).parentElement!
183
+ .parentElement as HTMLElement;
184
+
185
+ fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
186
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
187
+ fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
188
+ fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
189
+ expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
190
+
191
+ fireEvent.pointerDown(stage, { clientX: 100, pointerId: 1, button: 0 });
192
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
193
+ fireEvent.pointerMove(stage, { clientX: 200, pointerId: 1 });
194
+ fireEvent.pointerUp(stage, { clientX: 200, pointerId: 1 });
195
+ expect(onActiveIndexChange).toHaveBeenLastCalledWith(1);
196
+ });
197
+
198
+ it("keeps the arrows for desktop only", () => {
199
+ renderCoverflow();
200
+ expect(screen.getByRole("button", { name: "Next" }).className).toContain("lg:flex");
201
+ expect(screen.getByRole("button", { name: "Next" }).className).toContain("hidden");
202
+ });
203
+ });
204
+
178
205
  describe("caption and position", () => {
179
206
  it("renders the position on its own, with no separator dot, when there is no caption", () => {
180
207
  renderCoverflow({ positionLabel: "1 of 7" });
@@ -1,16 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useId, useRef, useState } from "react";
3
+ import { useId, useState } from "react";
4
4
  import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
+ import { useDragSwipe } from "../hooks/use-drag-swipe";
6
7
  import { EventPosterFallback } from "./event-poster-fallback";
7
8
 
8
9
  /**
9
10
  * Coverflow — the busiest-events browser: a 3D perspective lane with a large
10
11
  * 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).
12
+ * side.
13
+ *
14
+ * NOTHING MOVES ON ITS OWN. The lane used to drift ±40px on a loop; it was
15
+ * removed at product request — a stage that sways is read as a carousel that
16
+ * is about to advance, and the reader waits for it instead of touching it.
17
+ * The stage is driven by the reader: a horizontal drag (touch, pen or mouse)
18
+ * steps the ring one card in the drag's direction and the lane follows the
19
+ * finger a little on the way, so the gesture has weight. The prev/next arrows
20
+ * stay for desktop and keyboard; below `lg` they are hidden so nothing sits
21
+ * over the lead card — the swipe is the control there, and every card is
22
+ * still a focusable, named button for keyboard and screen-reader users.
14
23
  *
15
24
  * IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
16
25
  * SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
@@ -178,15 +187,12 @@ const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32
178
187
  const COVERFLOW_PILL_CLASS =
179
188
  "inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
180
189
 
181
- const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
182
- const COVERFLOW_DRIFT_PIXELS = 40;
183
-
184
190
  /** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
185
191
  * win, no matter how many cards are mid-transition and briefly promoted. */
186
192
  const COVERFLOW_LEAD_Z_INDEX = 6;
187
193
 
188
194
  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";
195
+ "absolute top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 lg:flex 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
196
 
191
197
  function wrapIndex(index: number, length: number): number {
192
198
  return ((index % length) + length) % length;
@@ -347,7 +353,6 @@ export function Coverflow({
347
353
  }: CoverflowProps) {
348
354
  const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
349
355
  const scopeClass = `coverflow-${scopeId}`;
350
- const containerRef = useRef<HTMLDivElement>(null);
351
356
 
352
357
  const length = items.length;
353
358
  const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
@@ -372,26 +377,21 @@ export function Coverflow({
372
377
  );
373
378
  }
374
379
 
375
- useEffect(() => {
376
- const node = containerRef.current;
377
- if (!node || typeof IntersectionObserver === "undefined") return undefined;
380
+ // No clamp at the ends: prev from 0 reports `length - 1` and next from
381
+ // `length - 1` reports 0, so the index the parent stores matches the ring.
382
+ const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
378
383
 
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
- }, []);
384
+ // A drag to the left means "next" — the ring turns the way the finger went.
385
+ // The lane follows at a third of the drag distance while the finger is down,
386
+ // then snaps back as the step lands.
387
+ const { dragX, isDragging, handlers } = useDragSwipe({
388
+ onSwipe: (direction) => goTo(active + direction),
389
+ disabled: length < 2,
390
+ });
388
391
 
389
392
  if (length === 0) return null;
390
393
 
391
394
  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
395
 
396
396
  /** How far the lead itself moved, the short way round — 0 on a settled render. */
397
397
  const activeDelta = ringOffset(step.to - step.from, length);
@@ -446,26 +446,10 @@ export function Coverflow({
446
446
  --cf-tier-base: 0.5714;
447
447
  }
448
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
449
  `}</style>
466
450
 
467
451
  <div
468
- ref={containerRef}
452
+ {...handlers}
469
453
  // CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
470
454
  // round the back of the ring, and that only ever happens off the left
471
455
  // and right edges — but `overflow-hidden` clips both axes, so it was
@@ -477,7 +461,11 @@ export function Coverflow({
477
461
  // container), so `overflow-x-hidden overflow-y-visible` is not a thing
478
462
  // the cascade will give you. `clip` carries no such rule, so the pair
479
463
  // 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]"
464
+ //
465
+ // `touch-action: pan-y` hands vertical gestures to the browser and
466
+ // horizontal ones to the drag handlers; `select-none` stops a mouse
467
+ // drag from selecting the captions underneath.
468
+ className="relative flex h-[300px] select-none items-center justify-center overflow-x-clip overflow-y-visible [touch-action:pan-y] [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
481
469
  >
482
470
  <div
483
471
  aria-hidden="true"
@@ -511,7 +499,13 @@ export function Coverflow({
511
499
  </>
512
500
  )}
513
501
 
514
- <div className="coverflow-lane relative h-full w-full [transform-style:preserve-3d] motion-reduce:[animation:none]">
502
+ <div
503
+ className={cn(
504
+ "coverflow-lane relative h-full w-full [transform-style:preserve-3d]",
505
+ !isDragging && "transition-transform duration-300 ease-out motion-reduce:transition-none"
506
+ )}
507
+ style={{ transform: `translateX(${dragX / 3}px)` }}
508
+ >
515
509
  {visibleCards.map(({ item, index, offset, suppressTransition }) => (
516
510
  <CoverflowCard
517
511
  key={item.id}