@bearmenu/ui 0.8.3 → 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 (194) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/chunk-637L7XQ3.js +85 -0
  3. package/dist/chunk-6XMYEZVR.js +26 -0
  4. package/dist/chunk-DNSXMHYX.js +8 -0
  5. package/dist/chunk-FDITZ2VM.js +16 -0
  6. package/dist/chunk-GPH6L4DX.js +55 -0
  7. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  8. package/dist/chunk-TT3WFS6S.js +379 -0
  9. package/dist/chunk-UXWAOISG.js +170 -0
  10. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  11. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +4 -127
  17. package/dist/components/category-deck.d.ts +50 -0
  18. package/dist/components/category-deck.js +128 -0
  19. package/dist/components/command.d.ts +15 -15
  20. package/dist/components/concept-card.d.ts +43 -0
  21. package/dist/components/concept-card.js +81 -0
  22. package/dist/components/coverflow.d.ts +123 -0
  23. package/dist/components/coverflow.js +300 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/day-rail-chip.d.ts +82 -0
  26. package/dist/components/day-rail-chip.js +105 -0
  27. package/dist/components/drift-rail.d.ts +28 -0
  28. package/dist/components/drift-rail.js +3 -0
  29. package/dist/components/event-card.d.ts +97 -0
  30. package/dist/components/event-card.js +161 -0
  31. package/dist/components/event-compact-row.d.ts +72 -0
  32. package/dist/components/event-compact-row.js +133 -0
  33. package/dist/components/event-day-header.d.ts +65 -0
  34. package/dist/components/event-day-header.js +95 -0
  35. package/dist/components/event-day-separator.d.ts +46 -0
  36. package/dist/components/event-day-separator.js +50 -0
  37. package/dist/components/event-detail-panel.d.ts +80 -0
  38. package/dist/components/event-detail-panel.js +251 -0
  39. package/dist/components/event-hub-hero.d.ts +34 -0
  40. package/dist/components/event-hub-hero.js +57 -0
  41. package/dist/components/event-index-row.d.ts +68 -0
  42. package/dist/components/event-index-row.js +84 -0
  43. package/dist/components/event-list-row-wide.d.ts +101 -0
  44. package/dist/components/event-list-row-wide.js +218 -0
  45. package/dist/components/event-list-row.d.ts +82 -0
  46. package/dist/components/event-list-row.js +173 -0
  47. package/dist/components/event-poster-fallback.d.ts +20 -0
  48. package/dist/components/event-poster-fallback.js +3 -0
  49. package/dist/components/event-types.d.ts +118 -0
  50. package/dist/components/event-types.js +2 -0
  51. package/dist/components/events-empty-state.d.ts +44 -0
  52. package/dist/components/events-empty-state.js +73 -0
  53. package/dist/components/facet-group.d.ts +93 -0
  54. package/dist/components/facet-group.js +222 -0
  55. package/dist/components/featured-rail-item.d.ts +42 -0
  56. package/dist/components/featured-rail-item.js +88 -0
  57. package/dist/components/hour-band.d.ts +68 -0
  58. package/dist/components/hour-band.js +53 -0
  59. package/dist/components/hour-group.d.ts +67 -0
  60. package/dist/components/hour-group.js +59 -0
  61. package/dist/components/icon.d.ts +18 -0
  62. package/dist/components/icon.js +31 -0
  63. package/dist/components/item.d.ts +2 -2
  64. package/dist/components/motion-icon-controls.d.ts +26 -0
  65. package/dist/components/motion-icon-controls.js +4 -0
  66. package/dist/components/motion-icon-set.d.ts +27 -0
  67. package/dist/components/motion-icon-set.js +139 -0
  68. package/dist/components/motion-icon.d.ts +44 -0
  69. package/dist/components/motion-icon.js +3 -0
  70. package/dist/components/multi-select-combobox.js +1 -1
  71. package/dist/components/place-about-tab.js +3 -3
  72. package/dist/components/place-details-mobile.js +3 -3
  73. package/dist/components/poster-stack.d.ts +30 -0
  74. package/dist/components/poster-stack.js +104 -0
  75. package/dist/components/poster-tape.d.ts +36 -0
  76. package/dist/components/poster-tape.js +64 -0
  77. package/dist/components/poster-wall.d.ts +25 -0
  78. package/dist/components/poster-wall.js +183 -0
  79. package/dist/components/quiet-stretch.d.ts +43 -0
  80. package/dist/components/quiet-stretch.js +54 -0
  81. package/dist/components/section-header.d.ts +53 -0
  82. package/dist/components/section-header.js +63 -0
  83. package/dist/components/segmented-nav.d.ts +62 -0
  84. package/dist/components/segmented-nav.js +54 -0
  85. package/dist/components/skeleton-card.js +1 -1
  86. package/dist/components/skeleton.d.ts +17 -0
  87. package/dist/components/skeleton.js +1 -1
  88. package/dist/components/spinner.d.ts +2 -2
  89. package/dist/components/tag-group.js +3 -26
  90. package/dist/components/tag.d.ts +2 -2
  91. package/dist/components/tag.js +1 -1
  92. package/dist/components/text.d.ts +3 -3
  93. package/dist/components/timeline-band.d.ts +46 -0
  94. package/dist/components/timeline-band.js +5 -0
  95. package/dist/components/venue-events-block.d.ts +143 -0
  96. package/dist/components/venue-events-block.js +222 -0
  97. package/dist/components/weekend-panel.d.ts +44 -0
  98. package/dist/components/weekend-panel.js +231 -0
  99. package/dist/lib/icon-registry.d.ts +58 -0
  100. package/dist/lib/icon-registry.js +39 -0
  101. package/package.json +2 -1
  102. package/src/components/accordion.stories.tsx +1 -1
  103. package/src/components/card.tsx +44 -1
  104. package/src/components/category-deck.stories.tsx +96 -0
  105. package/src/components/category-deck.tsx +227 -0
  106. package/src/components/combobox.stories.tsx +1 -1
  107. package/src/components/concept-card.stories.tsx +80 -0
  108. package/src/components/concept-card.tsx +143 -0
  109. package/src/components/coverflow.stories.tsx +240 -0
  110. package/src/components/coverflow.test.tsx +193 -0
  111. package/src/components/coverflow.tsx +550 -0
  112. package/src/components/currency-input.stories.tsx +3 -3
  113. package/src/components/day-rail-chip.stories.tsx +145 -0
  114. package/src/components/day-rail-chip.tsx +201 -0
  115. package/src/components/drift-rail.stories.tsx +92 -0
  116. package/src/components/drift-rail.tsx +86 -0
  117. package/src/components/empty-state.stories.tsx +3 -3
  118. package/src/components/event-card.stories.tsx +248 -0
  119. package/src/components/event-card.test.tsx +177 -0
  120. package/src/components/event-card.tsx +321 -0
  121. package/src/components/event-compact-row.stories.tsx +221 -0
  122. package/src/components/event-compact-row.tsx +268 -0
  123. package/src/components/event-day-header.stories.tsx +124 -0
  124. package/src/components/event-day-header.tsx +180 -0
  125. package/src/components/event-day-separator.stories.tsx +70 -0
  126. package/src/components/event-day-separator.tsx +95 -0
  127. package/src/components/event-detail-panel.stories.tsx +191 -0
  128. package/src/components/event-detail-panel.tsx +360 -0
  129. package/src/components/event-hub-hero.stories.tsx +66 -0
  130. package/src/components/event-hub-hero.tsx +110 -0
  131. package/src/components/event-index-row.stories.tsx +116 -0
  132. package/src/components/event-index-row.tsx +181 -0
  133. package/src/components/event-list-row-wide.stories.tsx +204 -0
  134. package/src/components/event-list-row-wide.tsx +386 -0
  135. package/src/components/event-list-row.stories.tsx +159 -0
  136. package/src/components/event-list-row.tsx +335 -0
  137. package/src/components/event-poster-fallback.stories.tsx +52 -0
  138. package/src/components/event-poster-fallback.tsx +31 -0
  139. package/src/components/event-types.ts +133 -0
  140. package/src/components/events-empty-state.stories.tsx +59 -0
  141. package/src/components/events-empty-state.tsx +131 -0
  142. package/src/components/facet-group.stories.tsx +247 -0
  143. package/src/components/facet-group.test.tsx +225 -0
  144. package/src/components/facet-group.tsx +352 -0
  145. package/src/components/featured-rail-item.stories.tsx +86 -0
  146. package/src/components/featured-rail-item.tsx +153 -0
  147. package/src/components/form.stories.tsx +1 -1
  148. package/src/components/hour-band.stories.tsx +159 -0
  149. package/src/components/hour-band.tsx +126 -0
  150. package/src/components/hour-group.stories.tsx +100 -0
  151. package/src/components/hour-group.tsx +124 -0
  152. package/src/components/icon.tsx +57 -0
  153. package/src/components/input-otp.stories.tsx +1 -1
  154. package/src/components/markdown-renderer.stories.tsx +1 -1
  155. package/src/components/motion-icon-controls.test.tsx +78 -0
  156. package/src/components/motion-icon-controls.tsx +552 -0
  157. package/src/components/motion-icon-set.stories.tsx +185 -0
  158. package/src/components/motion-icon-set.tsx +219 -0
  159. package/src/components/motion-icon.test.tsx +159 -0
  160. package/src/components/motion-icon.tsx +163 -0
  161. package/src/components/multi-select-combobox.stories.tsx +1 -1
  162. package/src/components/phone-frame.stories.tsx +1 -1
  163. package/src/components/poster-stack.stories.tsx +68 -0
  164. package/src/components/poster-stack.tsx +155 -0
  165. package/src/components/poster-tape.stories.tsx +70 -0
  166. package/src/components/poster-tape.tsx +122 -0
  167. package/src/components/poster-wall.stories.tsx +95 -0
  168. package/src/components/poster-wall.tsx +220 -0
  169. package/src/components/quiet-stretch.stories.tsx +62 -0
  170. package/src/components/quiet-stretch.tsx +115 -0
  171. package/src/components/rating.stories.tsx +3 -3
  172. package/src/components/searchable-select.stories.tsx +1 -1
  173. package/src/components/section-header.stories.tsx +70 -0
  174. package/src/components/section-header.tsx +134 -0
  175. package/src/components/segmented-nav.stories.tsx +118 -0
  176. package/src/components/segmented-nav.tsx +113 -0
  177. package/src/components/selection-bar.stories.tsx +1 -1
  178. package/src/components/skeleton-card.stories.tsx +3 -3
  179. package/src/components/skeleton.tsx +18 -0
  180. package/src/components/sliding-panels.stories.tsx +1 -1
  181. package/src/components/stepper.stories.tsx +1 -1
  182. package/src/components/sticky-container.stories.tsx +3 -3
  183. package/src/components/tag.tsx +11 -1
  184. package/src/components/timeline-band.stories.tsx +206 -0
  185. package/src/components/timeline-band.tsx +207 -0
  186. package/src/components/toggle-group.stories.tsx +1 -1
  187. package/src/components/tooltip.stories.tsx +2 -2
  188. package/src/components/venue-events-block.stories.tsx +216 -0
  189. package/src/components/venue-events-block.tsx +408 -0
  190. package/src/components/weekend-panel.stories.tsx +92 -0
  191. package/src/components/weekend-panel.tsx +322 -0
  192. package/src/globals.css +19 -0
  193. package/src/lib/icon-registry.ts +139 -0
  194. package/src/test/setup.ts +5 -1
@@ -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
+ };
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { EmptyState, SearchEmptyState, ErrorEmptyState } from "./empty-state";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof EmptyState> = {
5
5
  title: "Components/EmptyState",
6
6
  component: EmptyState,
7
7
  tags: ["autodocs"],
@@ -15,10 +15,10 @@ const meta = {
15
15
  options: ["sm", "md", "lg"],
16
16
  },
17
17
  },
18
- } satisfies Meta<typeof EmptyState>;
18
+ };
19
19
 
20
20
  export default meta;
21
- type Story = StoryObj<typeof meta>;
21
+ type Story = StoryObj<typeof EmptyState>;
22
22
 
23
23
  export const Default: Story = {
24
24
  args: {