@bearmenu/ui 0.8.4 → 0.8.7

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 (150) 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/card.d.ts +2 -2
  12. package/dist/components/card.js +4 -127
  13. package/dist/components/category-deck.d.ts +50 -0
  14. package/dist/components/category-deck.js +128 -0
  15. package/dist/components/concept-card.d.ts +43 -0
  16. package/dist/components/concept-card.js +81 -0
  17. package/dist/components/coverflow.d.ts +123 -0
  18. package/dist/components/coverflow.js +300 -0
  19. package/dist/components/day-rail-chip.d.ts +82 -0
  20. package/dist/components/day-rail-chip.js +105 -0
  21. package/dist/components/drift-rail.d.ts +28 -0
  22. package/dist/components/drift-rail.js +3 -0
  23. package/dist/components/event-card.d.ts +97 -0
  24. package/dist/components/event-card.js +164 -0
  25. package/dist/components/event-compact-row.d.ts +73 -0
  26. package/dist/components/event-compact-row.js +134 -0
  27. package/dist/components/event-day-header.d.ts +65 -0
  28. package/dist/components/event-day-header.js +95 -0
  29. package/dist/components/event-day-separator.d.ts +46 -0
  30. package/dist/components/event-day-separator.js +50 -0
  31. package/dist/components/event-detail-panel.d.ts +80 -0
  32. package/dist/components/event-detail-panel.js +251 -0
  33. package/dist/components/event-hub-hero.d.ts +34 -0
  34. package/dist/components/event-hub-hero.js +57 -0
  35. package/dist/components/event-index-row.d.ts +68 -0
  36. package/dist/components/event-index-row.js +84 -0
  37. package/dist/components/event-list-row-wide.d.ts +101 -0
  38. package/dist/components/event-list-row-wide.js +218 -0
  39. package/dist/components/event-list-row.d.ts +82 -0
  40. package/dist/components/event-list-row.js +173 -0
  41. package/dist/components/event-poster-fallback.d.ts +20 -0
  42. package/dist/components/event-poster-fallback.js +3 -0
  43. package/dist/components/event-types.d.ts +122 -0
  44. package/dist/components/event-types.js +2 -0
  45. package/dist/components/events-empty-state.d.ts +44 -0
  46. package/dist/components/events-empty-state.js +73 -0
  47. package/dist/components/facet-group.d.ts +93 -0
  48. package/dist/components/facet-group.js +222 -0
  49. package/dist/components/featured-rail-item.d.ts +42 -0
  50. package/dist/components/featured-rail-item.js +88 -0
  51. package/dist/components/hour-band.d.ts +68 -0
  52. package/dist/components/hour-band.js +53 -0
  53. package/dist/components/hour-group.d.ts +67 -0
  54. package/dist/components/hour-group.js +59 -0
  55. package/dist/components/motion-icon-controls.js +3 -379
  56. package/dist/components/multi-select-combobox.js +1 -1
  57. package/dist/components/place-about-tab.js +3 -3
  58. package/dist/components/place-details-mobile.js +3 -3
  59. package/dist/components/poster-stack.d.ts +30 -0
  60. package/dist/components/poster-stack.js +104 -0
  61. package/dist/components/poster-tape.d.ts +36 -0
  62. package/dist/components/poster-tape.js +64 -0
  63. package/dist/components/poster-wall.d.ts +25 -0
  64. package/dist/components/poster-wall.js +183 -0
  65. package/dist/components/quiet-stretch.d.ts +43 -0
  66. package/dist/components/quiet-stretch.js +54 -0
  67. package/dist/components/section-header.d.ts +53 -0
  68. package/dist/components/section-header.js +63 -0
  69. package/dist/components/segmented-nav.d.ts +62 -0
  70. package/dist/components/segmented-nav.js +54 -0
  71. package/dist/components/skeleton-card.js +1 -1
  72. package/dist/components/skeleton.d.ts +17 -0
  73. package/dist/components/skeleton.js +1 -1
  74. package/dist/components/tag-group.js +3 -26
  75. package/dist/components/tag.d.ts +2 -2
  76. package/dist/components/tag.js +1 -1
  77. package/dist/components/timeline-band.d.ts +46 -0
  78. package/dist/components/timeline-band.js +5 -0
  79. package/dist/components/venue-events-block.d.ts +143 -0
  80. package/dist/components/venue-events-block.js +222 -0
  81. package/dist/components/weekend-panel.d.ts +44 -0
  82. package/dist/components/weekend-panel.js +231 -0
  83. package/package.json +1 -1
  84. package/src/components/card.tsx +44 -1
  85. package/src/components/category-deck.stories.tsx +96 -0
  86. package/src/components/category-deck.tsx +227 -0
  87. package/src/components/concept-card.stories.tsx +80 -0
  88. package/src/components/concept-card.tsx +143 -0
  89. package/src/components/coverflow.stories.tsx +240 -0
  90. package/src/components/coverflow.test.tsx +193 -0
  91. package/src/components/coverflow.tsx +550 -0
  92. package/src/components/day-rail-chip.stories.tsx +145 -0
  93. package/src/components/day-rail-chip.tsx +201 -0
  94. package/src/components/drift-rail.stories.tsx +92 -0
  95. package/src/components/drift-rail.tsx +86 -0
  96. package/src/components/event-card.stories.tsx +248 -0
  97. package/src/components/event-card.test.tsx +188 -0
  98. package/src/components/event-card.tsx +329 -0
  99. package/src/components/event-compact-row.stories.tsx +221 -0
  100. package/src/components/event-compact-row.test.tsx +51 -0
  101. package/src/components/event-compact-row.tsx +270 -0
  102. package/src/components/event-day-header.stories.tsx +124 -0
  103. package/src/components/event-day-header.tsx +180 -0
  104. package/src/components/event-day-separator.stories.tsx +70 -0
  105. package/src/components/event-day-separator.tsx +95 -0
  106. package/src/components/event-detail-panel.stories.tsx +191 -0
  107. package/src/components/event-detail-panel.tsx +360 -0
  108. package/src/components/event-hub-hero.stories.tsx +66 -0
  109. package/src/components/event-hub-hero.tsx +110 -0
  110. package/src/components/event-index-row.stories.tsx +116 -0
  111. package/src/components/event-index-row.tsx +181 -0
  112. package/src/components/event-list-row-wide.stories.tsx +204 -0
  113. package/src/components/event-list-row-wide.tsx +386 -0
  114. package/src/components/event-list-row.stories.tsx +159 -0
  115. package/src/components/event-list-row.tsx +335 -0
  116. package/src/components/event-poster-fallback.stories.tsx +52 -0
  117. package/src/components/event-poster-fallback.tsx +31 -0
  118. package/src/components/event-types.ts +137 -0
  119. package/src/components/events-empty-state.stories.tsx +59 -0
  120. package/src/components/events-empty-state.tsx +131 -0
  121. package/src/components/facet-group.stories.tsx +247 -0
  122. package/src/components/facet-group.test.tsx +225 -0
  123. package/src/components/facet-group.tsx +352 -0
  124. package/src/components/featured-rail-item.stories.tsx +86 -0
  125. package/src/components/featured-rail-item.tsx +153 -0
  126. package/src/components/hour-band.stories.tsx +159 -0
  127. package/src/components/hour-band.tsx +126 -0
  128. package/src/components/hour-group.stories.tsx +100 -0
  129. package/src/components/hour-group.tsx +124 -0
  130. package/src/components/poster-stack.stories.tsx +68 -0
  131. package/src/components/poster-stack.tsx +155 -0
  132. package/src/components/poster-tape.stories.tsx +70 -0
  133. package/src/components/poster-tape.tsx +122 -0
  134. package/src/components/poster-wall.stories.tsx +95 -0
  135. package/src/components/poster-wall.tsx +220 -0
  136. package/src/components/quiet-stretch.stories.tsx +62 -0
  137. package/src/components/quiet-stretch.tsx +115 -0
  138. package/src/components/section-header.stories.tsx +70 -0
  139. package/src/components/section-header.tsx +134 -0
  140. package/src/components/segmented-nav.stories.tsx +118 -0
  141. package/src/components/segmented-nav.tsx +113 -0
  142. package/src/components/skeleton.tsx +18 -0
  143. package/src/components/tag.tsx +11 -1
  144. package/src/components/timeline-band.stories.tsx +206 -0
  145. package/src/components/timeline-band.tsx +207 -0
  146. package/src/components/venue-events-block.stories.tsx +216 -0
  147. package/src/components/venue-events-block.tsx +408 -0
  148. package/src/components/weekend-panel.stories.tsx +92 -0
  149. package/src/components/weekend-panel.tsx +322 -0
  150. package/src/globals.css +19 -0
@@ -0,0 +1,201 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Skeleton } from "./skeleton";
6
+ import type { DayRailChipLabels } from "./event-types";
7
+
8
+ /**
9
+ * DayRailChip — one day in the horizontally scrolling day rail.
10
+ *
11
+ * Three states the design distinguishes and that are easy to collapse by
12
+ * accident:
13
+ * - ACTIVE: FILLED — accent ground, accent-foreground label, count and dot.
14
+ * - HAS EVENTS: card ground, normal foreground, an accent density dot.
15
+ * - EMPTY: muted text, NO dot, an em dash instead of "0". A day with nothing
16
+ * on stays in the rail — ten of fourteen days routinely read empty, and
17
+ * hiding them makes the rail lie about how far it reaches — but the caller
18
+ * decides whether it is interactive, and a rail that NAVIGATES has nothing
19
+ * to send an empty day to.
20
+ *
21
+ * ── Why active is filled ──────────────────────────────────────────────────
22
+ * It was an accent border and accent text. Measured against the real tokens
23
+ * that is a 1px hairline plus a label at 1.16:1 against its inactive
24
+ * neighbours in light mode — and in dark, where `--primary` (0.66) is DIMMER
25
+ * than `--foreground` (0.96), the active chip read as the recessed one at
26
+ * 2.86:1. Survivable while a large pinned day header also announced the day;
27
+ * not survivable once the rail became the only indicator.
28
+ *
29
+ * An eye scanning fourteen near-identical 62px boxes horizontally, in
30
+ * peripheral vision, while the page moves, resolves AREA and LUMINANCE STEP
31
+ * long before it resolves the hue of a hairline or the weight of 11.5px type.
32
+ * The fill presents ~3,100px² at 5.97:1 (light) / 6.09:1 (dark). A tinted
33
+ * ground would not do it: `bg-accent/10` and `--accent-subtle` both measure
34
+ * ~1.15:1 against the chip's own card, which is no signal at all.
35
+ *
36
+ * `--accent`, deliberately, not `--gradient-primary`: white on that gradient's
37
+ * top stop is 4.45:1, under AA for text, and the gradient has no `.dark`
38
+ * override so the chip's ink direction would invert against the rest of the
39
+ * dark theme. `--accent` also already means "current" here — the bottom nav's
40
+ * active tab uses it.
41
+ *
42
+ * The border stays 1px in BOTH states. Thickening it on active would change
43
+ * the box model, and these chips sit in an equal-width grid that would reflow
44
+ * on every scroll-spy tick.
45
+ *
46
+ * Renders a link when `href` is given, a button when only `onClick` is, and a
47
+ * plain div otherwise. The button announces `aria-current`, not
48
+ * `aria-pressed`: this is the current item in a set of days, not a toggle.
49
+ */
50
+
51
+ export type DayRailChipLinkProps = {
52
+ href: string;
53
+ className?: string;
54
+ children: React.ReactNode;
55
+ "aria-current"?: "page";
56
+ onClick?: () => void;
57
+ };
58
+
59
+ function DefaultLink({ href, ...props }: DayRailChipLinkProps) {
60
+ return <a href={href} {...props} />;
61
+ }
62
+
63
+ export type DayRailChipProps = {
64
+ /** Short weekday label, already localised — "Today", "Fri", "Sâm". */
65
+ label: string;
66
+ /** Day of month, as displayed. */
67
+ dayNumber: string;
68
+ /** Events on this day, after any active filter. */
69
+ count: number;
70
+ isActive?: boolean;
71
+ /** Keeps the bold weight when inactive. */
72
+ isWeekend?: boolean;
73
+ labels: DayRailChipLabels;
74
+ href?: string;
75
+ linkComponent?: React.ComponentType<DayRailChipLinkProps>;
76
+ onClick?: () => void;
77
+ className?: string;
78
+ };
79
+
80
+ export function DayRailChip({
81
+ label,
82
+ dayNumber,
83
+ count,
84
+ isActive,
85
+ isWeekend,
86
+ labels,
87
+ href,
88
+ linkComponent: LinkComponent = DefaultLink,
89
+ onClick,
90
+ className,
91
+ }: DayRailChipProps) {
92
+ const hasEvents = count > 0;
93
+
94
+ const rootClasses = cn(
95
+ "w-[62px] shrink-0 rounded-xl border px-1 py-2 text-center",
96
+ "transition-colors duration-150 motion-reduce:transition-none",
97
+ isActive ? "border-accent bg-accent" : "border-border bg-card",
98
+ (href || onClick) &&
99
+ // `ring-offset-background` is not decoration: Tailwind's default offset
100
+ // colour is white, which painted a white halo around a focused chip on
101
+ // the dark rail.
102
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
103
+ className
104
+ );
105
+
106
+ const body = (
107
+ <>
108
+ <div
109
+ className={cn(
110
+ "text-[11.5px]",
111
+ isActive || isWeekend ? "font-bold" : "font-semibold",
112
+ isActive
113
+ ? "text-accent-foreground"
114
+ : hasEvents
115
+ ? "text-foreground"
116
+ // NOT `/70`, which measured 2.96:1 light and 4.17:1 dark — an AA
117
+ // failure in both. An empty day is inert content, not a disabled
118
+ // form control, so nothing exempts it. The em dash and the
119
+ // missing dot already say "empty"; `muted-foreground` against
120
+ // `foreground` is still an 18.8 → 5.5 step and plainly quieter.
121
+ : "text-muted-foreground"
122
+ )}
123
+ >
124
+ {label} {dayNumber}
125
+ </div>
126
+
127
+ <div className="mt-[3px] flex items-center justify-center gap-1">
128
+ {/* Kept on the active chip, not dropped: losing it would shift the
129
+ count 5px sideways every time the day changes under the scroll spy.
130
+ Inactive is full `bg-accent`, not `bg-primary/40` — at 40% alpha it
131
+ measured 2.46:1 / 1.93:1 (invisible at 4px), and in light mode
132
+ `--primary` is near-black, so the "warm density dot" was grey. */}
133
+ {hasEvents && (
134
+ <span
135
+ className={cn(
136
+ "h-1 w-1 rounded-full",
137
+ isActive ? "bg-accent-foreground" : "bg-accent"
138
+ )}
139
+ aria-hidden
140
+ />
141
+ )}
142
+ <span
143
+ className={cn(
144
+ // Full opacity on active, never dimmed: `/80` lands at 4.48:1, an
145
+ // AA fail by 0.02. Hierarchy inside a 50px box comes from the
146
+ // label's weight and the type-size step, which is enough.
147
+ "font-mono text-[10.5px] tabular-nums",
148
+ isActive ? "text-accent-foreground" : "text-muted-foreground"
149
+ )}
150
+ >
151
+ {hasEvents ? count : <span aria-label={labels.noEvents}>—</span>}
152
+ </span>
153
+ </div>
154
+ </>
155
+ );
156
+
157
+ if (href) {
158
+ return (
159
+ <LinkComponent
160
+ href={href}
161
+ className={rootClasses}
162
+ aria-current={isActive ? "page" : undefined}
163
+ onClick={onClick}
164
+ >
165
+ {body}
166
+ </LinkComponent>
167
+ );
168
+ }
169
+
170
+ if (onClick) {
171
+ return (
172
+ <button
173
+ type="button"
174
+ className={rootClasses}
175
+ // `aria-current`, not `aria-pressed`. The rail used to FILTER, where a
176
+ // toggle semantic was right; it now navigates, and this is the current
177
+ // day in a set of days.
178
+ aria-current={isActive ? "true" : undefined}
179
+ onClick={onClick}
180
+ >
181
+ {body}
182
+ </button>
183
+ );
184
+ }
185
+
186
+ return <div className={rootClasses}>{body}</div>;
187
+ }
188
+
189
+ export type DayRailChipSkeletonProps = {
190
+ className?: string;
191
+ };
192
+
193
+ /**
194
+ * Mirrors one `<DayRailChip>`'s footprint (62×50, 12px radius — the source
195
+ * design's own day-rail skeleton geometry, which also happens to match this
196
+ * chip's rendered size: label + count rows inside `py-2` padding). The caller
197
+ * repeats it with the rail's own `gap-2` to build the loading rail.
198
+ */
199
+ export function DayRailChipSkeleton({ className }: DayRailChipSkeletonProps) {
200
+ return <Skeleton aria-hidden="true" className={cn("h-[50px] w-[62px] shrink-0", className)} />;
201
+ }
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { DriftRail } from "./drift-rail";
3
+ import { FeaturedRailItem } from "./featured-rail-item";
4
+
5
+ const CHIP_LABELS = [
6
+ "Live music",
7
+ "Rooftop",
8
+ "Free entry",
9
+ "Late night",
10
+ "DJ set",
11
+ "Comedy",
12
+ "Art walk",
13
+ "Brunch",
14
+ ];
15
+
16
+ function Chip({ label }: { label: string }) {
17
+ return (
18
+ <span className="inline-flex h-9 shrink-0 items-center rounded-full border border-border bg-card px-4 text-sm font-medium">
19
+ {label}
20
+ </span>
21
+ );
22
+ }
23
+
24
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
25
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
26
+ // name portably (TS2742).
27
+ const meta: Meta<typeof DriftRail> = {
28
+ title: "Events/DriftRail",
29
+ component: DriftRail,
30
+ tags: ["autodocs"],
31
+ parameters: {
32
+ docs: {
33
+ description: {
34
+ component:
35
+ "An infinite horizontal marquee. Duplicates its children once so a `translateX(-50%)` loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), and stops outright under `prefers-reduced-motion`.",
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof DriftRail>;
43
+
44
+ export const Chips: Story = {
45
+ render: () => (
46
+ <div className="max-w-[420px]">
47
+ <DriftRail>
48
+ {CHIP_LABELS.map((label) => (
49
+ <Chip key={label} label={label} />
50
+ ))}
51
+ </DriftRail>
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ /** Two rails at once — the design's opposing-direction pair, forward at 42s and reverse at 52s. */
57
+ export const OpposingPair: Story = {
58
+ render: () => (
59
+ <div className="max-w-[420px] space-y-4">
60
+ <DriftRail>
61
+ {CHIP_LABELS.slice(0, 5).map((label) => (
62
+ <Chip key={label} label={label} />
63
+ ))}
64
+ </DriftRail>
65
+ <DriftRail reverse durationSeconds={52}>
66
+ {CHIP_LABELS.slice(3).map((label) => (
67
+ <Chip key={label} label={label} />
68
+ ))}
69
+ </DriftRail>
70
+ </div>
71
+ ),
72
+ };
73
+
74
+ /** Wrapping the featured rail's own card — the rail supplies the motion, the card stays static content. */
75
+ export const WithFeaturedItems: Story = {
76
+ render: () => (
77
+ <div className="max-w-[420px]">
78
+ <DriftRail durationSeconds={30}>
79
+ {["Warehouse Rave", "Jazz on the Rooftop", "Open Mic Night", "Street Food Market"].map(
80
+ (title) => (
81
+ <FeaturedRailItem
82
+ key={title}
83
+ title={title}
84
+ venue="Atelier Café"
85
+ note="Sat 5 · 20:00"
86
+ />
87
+ )
88
+ )}
89
+ </DriftRail>
90
+ </div>
91
+ ),
92
+ };
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef } from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * DriftRail — an infinite horizontal marquee for logos, tag chips or small
8
+ * poster tiles. The caller passes one set of children; the component renders
9
+ * a second, `aria-hidden` copy right after it so a `translateX(-50%)` loop is
10
+ * seamless. Because the loop depends on the duplicate being pixel-identical
11
+ * to the first pass, keep `children` a fixed, ordered list — no per-render
12
+ * randomisation.
13
+ *
14
+ * Motion is scoped to this instance via a generated id (no shared stylesheet,
15
+ * no class collisions between multiple rails on one page) and is paused two
16
+ * ways, per the source design: an `IntersectionObserver` stops the loop the
17
+ * moment the rail scrolls off-screen, and `prefers-reduced-motion: reduce`
18
+ * removes the animation outright rather than merely slowing it.
19
+ */
20
+ export type DriftRailProps = {
21
+ children: React.ReactNode;
22
+ /** Runs the loop right-to-left instead of left-to-right. */
23
+ reverse?: boolean;
24
+ durationSeconds?: number;
25
+ /** Pauses the loop while the pointer hovers the rail. */
26
+ pauseOnHover?: boolean;
27
+ className?: string;
28
+ };
29
+
30
+ const DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
31
+
32
+ export function DriftRail({
33
+ children,
34
+ reverse = false,
35
+ durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
36
+ pauseOnHover = true,
37
+ className,
38
+ }: DriftRailProps) {
39
+ const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
40
+ const scopeClass = `drift-rail-${scopeId}`;
41
+ const containerRef = useRef<HTMLDivElement>(null);
42
+
43
+ useEffect(() => {
44
+ const node = containerRef.current;
45
+ if (!node || typeof IntersectionObserver === "undefined") return undefined;
46
+
47
+ const observer = new IntersectionObserver(
48
+ ([entry]) => {
49
+ node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
50
+ },
51
+ { threshold: 0 }
52
+ );
53
+ observer.observe(node);
54
+ return () => observer.disconnect();
55
+ }, []);
56
+
57
+ return (
58
+ <div ref={containerRef} className={cn(scopeClass, "overflow-hidden py-1.5", className)}>
59
+ <style>{`
60
+ .${scopeClass} .drift-rail-run {
61
+ animation: ${scopeClass}-run ${durationSeconds}s linear infinite;
62
+ animation-direction: ${reverse ? "reverse" : "normal"};
63
+ }
64
+ .${scopeClass}[data-offscreen="true"] .drift-rail-run {
65
+ animation-play-state: paused;
66
+ }
67
+ @keyframes ${scopeClass}-run {
68
+ from { transform: translateX(0); }
69
+ to { transform: translateX(-50%); }
70
+ }
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .${scopeClass} .drift-rail-run {
73
+ animation: none;
74
+ }
75
+ }
76
+ ${pauseOnHover ? `.${scopeClass}:hover .drift-rail-run { animation-play-state: paused; }` : ""}
77
+ `}</style>
78
+ <div className="drift-rail-run flex w-max items-stretch gap-3.5 motion-reduce:[animation:none]">
79
+ {children}
80
+ <div className="contents" aria-hidden="true">
81
+ {children}
82
+ </div>
83
+ </div>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,248 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Flame, Music } from "lucide-react";
3
+ import { EventCard, EventCardSkeleton } from "./event-card";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = {
7
+ free: "Free",
8
+ liveNow: "Live now",
9
+ };
10
+
11
+ const formatters: EventFormatters = {
12
+ time: (iso) =>
13
+ new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
14
+ day: (iso) =>
15
+ new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
16
+ price: (amount) => `${amount} RON`,
17
+ };
18
+
19
+ const event: EventPreview = {
20
+ id: "e1",
21
+ title: "Psihotrop — From the Beginning to Infinity",
22
+ location: "Atelier Café",
23
+ place_name: "Atelier Café",
24
+ place_slug: "atelier-cafe",
25
+ starts_at: "2026-09-05T20:00:00",
26
+ entry_fee: 50,
27
+ };
28
+
29
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
30
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
31
+ // name portably (TS2742).
32
+ const meta: Meta<typeof EventCard> = {
33
+ title: "Events/EventCard",
34
+ component: EventCard,
35
+ tags: ["autodocs"],
36
+ args: { event, labels, formatters },
37
+ decorators: [
38
+ (Story) => (
39
+ <div className="w-[220px]">
40
+ <Story />
41
+ </div>
42
+ ),
43
+ ],
44
+ parameters: {
45
+ docs: {
46
+ description: {
47
+ component:
48
+ "Pass `href` **and** `onClick` in production: the anchor keeps the card crawlable and middle-clickable, while `onClick` (calling `preventDefault()`) opens the detail sheet. A card rendered as a bare `<button>` is what stripped every event link out of the hub's rendered DOM.",
49
+ },
50
+ },
51
+ },
52
+ };
53
+
54
+ export default meta;
55
+ type Story = StoryObj<typeof EventCard>;
56
+
57
+ export const Default: Story = {};
58
+
59
+ /** `entry_fee: 0` is explicitly free and earns the green pill. */
60
+ export const FreeEntry: Story = {
61
+ args: { event: { ...event, entry_fee: 0 } },
62
+ };
63
+
64
+ /** An absent `entry_fee` is *unknown*, not free — no pill, no price. */
65
+ export const UnknownEntryFee: Story = {
66
+ args: { event: { ...event, entry_fee: undefined } },
67
+ };
68
+
69
+ /** Overlay pills: hype badge top-left, category tag bottom-left. */
70
+ export const WithBadgeAndTag: Story = {
71
+ args: {
72
+ event: { ...event, entry_fee: 0 },
73
+ badge: (
74
+ <>
75
+ <Flame className="h-3 w-3" aria-hidden />
76
+ Busy
77
+ </>
78
+ ),
79
+ tagLabel: (
80
+ <>
81
+ <Music className="h-3 w-3" aria-hidden />
82
+ Live music
83
+ </>
84
+ ),
85
+ },
86
+ };
87
+
88
+ /**
89
+ * `tags` — display labels rendered beneath the venue/meta line, distinct from
90
+ * `tagLabel`'s single overlay pill on the poster.
91
+ */
92
+ export const WithTags: Story = {
93
+ args: { tags: ["Live Music", "Rock", "18+"] },
94
+ };
95
+
96
+ /** The live line is composed by the caller — clause order differs per locale. */
97
+ export const LiveNow: Story = {
98
+ args: { liveStatus: "Live now · ends around 23:00" },
99
+ };
100
+
101
+ /** The featured slot: larger title. Still uncut. */
102
+ export const Emphasis: Story = {
103
+ args: { emphasis: true },
104
+ decorators: [
105
+ (Story) => (
106
+ <div className="w-[320px]">
107
+ <Story />
108
+ </div>
109
+ ),
110
+ ],
111
+ };
112
+
113
+ const LONG_TITLE_EVENT: EventPreview = {
114
+ ...event,
115
+ title: "Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score",
116
+ };
117
+
118
+ /**
119
+ * THE TITLE IS NEVER TRUNCATED, in any variant. Real listings run long
120
+ * ("Movie Night: Futni m…", "Juventus vs AC Milan…" were the reported bug),
121
+ * and the title is the one thing the card exists to say. It wraps to as many
122
+ * lines as it needs and the card grows downward.
123
+ */
124
+ export const LongTitle: Story = {
125
+ args: { event: LONG_TITLE_EVENT },
126
+ };
127
+
128
+ /** The same long title across every variant, so no combination quietly clips it. */
129
+ export const LongTitleEveryVariant: Story = {
130
+ render: () => (
131
+ <div className="flex items-start gap-4">
132
+ <div className="w-[150px]">
133
+ <EventCard
134
+ event={LONG_TITLE_EVENT}
135
+ labels={labels}
136
+ formatters={formatters}
137
+ posterAspect="4/3"
138
+ />
139
+ </div>
140
+ <div className="w-[150px]">
141
+ <EventCard
142
+ event={LONG_TITLE_EVENT}
143
+ labels={labels}
144
+ formatters={formatters}
145
+ posterAspect="3/4"
146
+ />
147
+ </div>
148
+ <div className="w-[150px]">
149
+ <EventCard
150
+ event={LONG_TITLE_EVENT}
151
+ labels={labels}
152
+ formatters={formatters}
153
+ posterAspect="4/5"
154
+ />
155
+ </div>
156
+ <div className="w-[220px]">
157
+ <EventCard event={LONG_TITLE_EVENT} labels={labels} formatters={formatters} emphasis />
158
+ </div>
159
+ </div>
160
+ ),
161
+ decorators: [
162
+ (Story) => (
163
+ <div className="w-[760px]">
164
+ <Story />
165
+ </div>
166
+ ),
167
+ ],
168
+ parameters: {
169
+ docs: {
170
+ description: {
171
+ story:
172
+ "`items-start` on the row is what keeps a three-line title from stretching its neighbours — the pattern any grid of these cards should copy.",
173
+ },
174
+ },
175
+ },
176
+ };
177
+
178
+ /** 3/4 poster — the hub's original portrait deck/rail/tape ratio. */
179
+ export const PortraitPoster: Story = {
180
+ args: { posterAspect: "3/4", showPrice: true },
181
+ };
182
+
183
+ /**
184
+ * 4:3 (the house shape, shared with dish/drink/place) beside 4:5 (the
185
+ * Instagram source ratio, used by the hub's editorial poster surfaces —
186
+ * `CategoryDeck`, `PosterTape`, `Coverflow`). Side by side so the crop
187
+ * difference is visible rather than guessed at.
188
+ */
189
+ export const HouseVsInstagramAspect: Story = {
190
+ render: () => (
191
+ <div className="flex gap-4">
192
+ <div className="w-[150px]">
193
+ <EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/3" />
194
+ </div>
195
+ <div className="w-[150px]">
196
+ <EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/5" />
197
+ </div>
198
+ </div>
199
+ ),
200
+ decorators: [
201
+ (Story) => (
202
+ <div className="w-[340px]">
203
+ <Story />
204
+ </div>
205
+ ),
206
+ ],
207
+ };
208
+
209
+ /** The timeline supplies its own hour heading, so the card drops the meta line. */
210
+ export const InsideAnHourGroup: Story = {
211
+ args: { hideWhen: true },
212
+ };
213
+
214
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
215
+ export const Loading: Story = {
216
+ render: () => <EventCardSkeleton />,
217
+ };
218
+
219
+ /** Skeleton beside the real card, so any geometry drift is visible at a glance. */
220
+ export const LoadingVsLoaded: Story = {
221
+ render: () => (
222
+ <div className="flex gap-4">
223
+ <EventCardSkeleton />
224
+ <EventCard event={event} labels={labels} formatters={formatters} />
225
+ </div>
226
+ ),
227
+ decorators: [
228
+ (Story) => (
229
+ <div className="w-[460px]">
230
+ <Story />
231
+ </div>
232
+ ),
233
+ ],
234
+ };
235
+
236
+ /** With a resolved poster. The caller composes the CDN URL; the DS never does. */
237
+ export const WithPoster: Story = {
238
+ args: {
239
+ event: {
240
+ ...event,
241
+ poster_url:
242
+ "data:image/svg+xml;utf8," +
243
+ encodeURIComponent(
244
+ `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#c8b8a8"/><text x="50%" y="50%" font-family="monospace" font-size="12" fill="#5a4a3a" text-anchor="middle">poster</text></svg>`
245
+ ),
246
+ },
247
+ },
248
+ };